Skip to main content

heliosdb_proxy/
server.rs

1//! Proxy Server Implementation
2//!
3//! Main server that accepts client connections and routes them to backends.
4//! Implements PostgreSQL wire protocol forwarding with TWR (Transparent Write Routing).
5
6use crate::admin::{AdminServer, AdminState, ConfigSnapshot, NodeSnapshot};
7#[cfg(feature = "ha-tr")]
8use crate::backend::{tls::default_client_config, BackendConfig, TlsMode};
9use crate::client_tls::{build_tls_acceptor, ClientStream};
10use crate::config::{HbaAction, HbaRule, NodeConfig, NodeRole, ProxyConfig, TrMode};
11#[cfg(feature = "wasm-plugins")]
12use crate::protocol::QueryMessage;
13use crate::protocol::{
14    ErrorResponse, Message, MessageType, ProtocolCodec, StartupMessage, TransactionStatus,
15};
16use crate::{ProxyError, Result};
17use arc_swap::ArcSwap;
18use bytes::{BufMut, BytesMut};
19use dashmap::DashMap;
20use std::collections::{HashMap, HashSet};
21use std::net::SocketAddr;
22use std::sync::atomic::{AtomicU64, Ordering};
23use std::sync::Arc;
24use std::time::Duration;
25use tokio::io::{AsyncReadExt, AsyncWriteExt};
26use tokio::net::{TcpListener, TcpStream};
27use tokio::sync::{broadcast, RwLock};
28use uuid::Uuid;
29
30// Pool-modes feature imports
31#[cfg(feature = "pool-modes")]
32use crate::pool::lease::ClientId;
33#[cfg(feature = "pool-modes")]
34use crate::pool::{ConnectionPoolManager, PoolModeConfig, PoolingMode};
35#[cfg(feature = "pool-modes")]
36use crate::NodeEndpoint;
37
38// WASM plugin system imports
39#[cfg(feature = "wasm-plugins")]
40use crate::plugins::{
41    AuthRequest as PluginAuthRequest, AuthResult, HookContext, HookType, Identity, PluginManager,
42    PostQueryOutcome, PreQueryResult, QueryContext, RouteResult,
43};
44
45/// Proxy server
46pub struct ProxyServer {
47    config: ProxyConfig,
48    state: Arc<ServerState>,
49    shutdown_tx: broadcast::Sender<()>,
50    /// Path the config was loaded from, retained so `SIGHUP` can re-read it
51    /// for a zero-downtime reload (Batch H). `None` when the config was built
52    /// from CLI flags/defaults rather than a file.
53    config_path: Option<String>,
54}
55
56/// Stand-in "signal stream" on platforms without Unix signals: its `recv()`
57/// never resolves, so the `SIGHUP` select arm is simply inert there.
58#[cfg(not(unix))]
59struct HangupNever;
60#[cfg(not(unix))]
61impl HangupNever {
62    async fn recv(&mut self) -> Option<()> {
63        std::future::pending().await
64    }
65}
66
67/// Build the BackendConfig template the time-travel replay engine
68/// uses for its target connection. The replay handler swaps in
69/// `target_host` / `target_port` per request; everything else
70/// (auth, TLS policy, timeouts) comes from this template.
71///
72/// Auth defaults to the bare PostgreSQL `postgres` superuser without
73/// a password — sensible for local development against `trust` auth,
74/// never for production. Per-call credential overrides on
75/// ReplayRequestBody land in FU-21.
76///
77/// `_config` is kept in the signature so future iterations can pull
78/// shared TLS / timeout settings from the proxy config without
79/// changing the call site.
80#[cfg(feature = "ha-tr")]
81fn build_replay_backend_template(_config: &ProxyConfig) -> BackendConfig {
82    BackendConfig {
83        host: "placeholder".to_string(),
84        port: 0,
85        user: "postgres".to_string(),
86        password: None,
87        database: None,
88        application_name: Some("heliosdb-proxy-replay".to_string()),
89        tls_mode: TlsMode::Disable,
90        connect_timeout: Duration::from_secs(5),
91        query_timeout: Duration::from_secs(30),
92        tls_config: default_client_config(),
93    }
94}
95
96/// Cheap query-shape fingerprint for the anomaly detector. Replaces
97/// numeric and string literals with `?` placeholders, lower-cases
98/// keywords, and collapses whitespace. Same shape regardless of
99/// literal values — `SELECT * FROM users WHERE id = 1` and
100/// `SELECT * FROM users WHERE id = 99` map to the same fingerprint.
101///
102/// Not a parser. The analytics module has the canonical normaliser
103/// when query-analytics is on; this is a lightweight standalone so
104/// the anomaly detector works even when analytics is off.
105#[cfg(feature = "anomaly-detection")]
106fn anomaly_fingerprint(sql: &str) -> String {
107    let mut out = String::with_capacity(sql.len());
108    let mut in_single = false;
109    let mut prev_space = false;
110    let mut chars = sql.chars().peekable();
111    while let Some(c) = chars.next() {
112        if c == '\'' {
113            in_single = !in_single;
114            // Replace the entire string literal (open + body +
115            // close) with a single ?.
116            if in_single {
117                out.push('?');
118                while let Some(&n) = chars.peek() {
119                    chars.next();
120                    if n == '\'' {
121                        in_single = false;
122                        break;
123                    }
124                }
125                prev_space = false;
126                continue;
127            }
128        }
129        if c.is_ascii_digit() {
130            if !out.ends_with('?') {
131                out.push('?');
132            }
133            // Skip the rest of the number.
134            while matches!(chars.peek(), Some(c) if c.is_ascii_digit() || *c == '.') {
135                chars.next();
136            }
137            prev_space = false;
138            continue;
139        }
140        if c.is_ascii_whitespace() {
141            if !prev_space && !out.is_empty() {
142                out.push(' ');
143                prev_space = true;
144            }
145            continue;
146        }
147        out.push(c.to_ascii_lowercase());
148        prev_space = false;
149    }
150    out.trim_end().to_string()
151}
152
153/// Server runtime state
154struct ServerState {
155    /// Active client sessions. A `DashMap` (not a `tokio::RwLock<HashMap>`) so
156    /// register/deregister are synchronous and lock-free-sharded — which lets a
157    /// `SessionGuard`'s `Drop` remove the entry on ANY exit, including a panic
158    /// unwind, and removes a per-connection async write-lock from the accept and
159    /// teardown paths.
160    sessions: DashMap<Uuid, Arc<ClientSession>>,
161    /// Node health status
162    // Read-mostly: only the periodic health checker writes (a full-map
163    // swap), every query reads. ArcSwap makes the per-query read a single
164    // lock-free atomic load with no await, no semaphore, no guard held
165    // across the routing awaits.
166    health: ArcSwap<HashMap<String, NodeHealth>>,
167    /// Write-serialization lock for `health`. Every reader stays lock-free on
168    /// the ArcSwap; every *writer* (periodic checker, in-band demotion, SIGHUP
169    /// reconcile) holds this across its load → clone → mutate → store so the
170    /// non-atomic read-modify-write cannot lose updates under concurrency.
171    health_write: parking_lot::Mutex<()>,
172    /// Live, reloadable proxy configuration (Batch H). The accept loop snapshots
173    /// this per new connection and the health checker reads it each tick, so a
174    /// SIGHUP that swaps it takes effect for new connections and node health
175    /// without dropping any in-flight session. The fields that can only be
176    /// applied at startup (listen/admin socket addresses) are ignored on reload
177    /// with a warning. Existing connections keep the snapshot they started with.
178    live_config: ArcSwap<ProxyConfig>,
179    /// Metrics
180    metrics: ServerMetrics,
181    /// Query-cancellation routing. Maps the BackendKeyData (pid, secret)
182    /// the backend handed to the client onto the backend address that
183    /// issued it, so a later out-of-band CancelRequest (which arrives on a
184    /// fresh connection) can be forwarded to the right backend instead of
185    /// being dropped. Bounded; best-effort.
186    cancel_map: Arc<DashMap<(u32, u32), String>>,
187    /// Insertion order of `cancel_map` keys, so an overflow evicts the OLDEST
188    /// entries (FIFO) instead of clearing the whole map — a busy proxy no
189    /// longer loses every in-flight cancel registration at once.
190    cancel_order: Arc<parking_lot::Mutex<std::collections::VecDeque<(u32, u32)>>>,
191    /// Client-facing TLS acceptor, built from `[tls]` config when enabled.
192    /// `None` => the proxy rejects SSLRequests with `N` (plaintext only).
193    tls_acceptor: Option<tokio_rustls::TlsAcceptor>,
194    /// Proxy-terminated SCRAM auth state. `Some` when `[auth] mode = "scram"`:
195    /// the proxy authenticates clients itself against this user list instead
196    /// of relaying their credentials to the backend.
197    auth_file: Option<Arc<crate::auth_scram::AuthFile>>,
198    /// Traffic-mirror handle. `Some` when `[mirror] enabled`: the data path
199    /// offers write statements to a background mirror worker.
200    mirror: Option<crate::mirror::MirrorHandle>,
201    /// Migration cutover switch. When `Some`, NEW client connections are
202    /// transparently redirected to the promoted target (the former mirror)
203    /// instead of the configured primary. Set via POST /api/migration/cutover.
204    cutover: Arc<ArcSwap<Option<Arc<crate::mirror::CutoverTarget>>>>,
205    /// Load balancer state
206    lb_state: LoadBalancerState,
207    /// SQL-comment routing-hint parser. `Some` when `[routing_hints] enabled`
208    /// and the `routing-hints` feature is compiled in; the parser's own
209    /// `strip_hints` flag records whether to rewrite the SQL before forwarding.
210    /// Applied per query, taking precedence over default verb routing.
211    #[cfg(feature = "routing-hints")]
212    hint_parser: Option<crate::routing::HintParser>,
213    /// Multi-dimensional rate limiter. `Some` when `[rate_limit] enabled`;
214    /// every query is checked against it before being forwarded to a backend.
215    #[cfg(feature = "rate-limiting")]
216    rate_limiter: Option<Arc<crate::rate_limit::RateLimiter>>,
217    /// Per-node circuit breaker manager. `Some` when `[circuit_breaker]
218    /// enabled`. Records per-node success/failure on the forward path, excludes
219    /// open nodes from read selection, and fast-fails queries to an open node.
220    #[cfg(feature = "circuit-breaker")]
221    circuit_breaker: Option<Arc<crate::circuit_breaker::CircuitBreakerManager>>,
222    /// Query analytics engine. `Some` when `[analytics] enabled`. Every
223    /// forwarded query is recorded (fingerprint, latency, slow-query log).
224    #[cfg(feature = "query-analytics")]
225    analytics: Option<Arc<crate::analytics::QueryAnalytics>>,
226    /// Query-result cache (L1 hot / L2 warm). `Some` when `[cache] enabled`.
227    /// Read SELECTs are served from it; writes invalidate referenced tables.
228    #[cfg(feature = "query-cache")]
229    query_cache: Option<Arc<crate::cache::QueryCache>>,
230    /// SQL query rewriter. `Some` when `[query_rewrite] enabled` with rules.
231    /// Rewrites the query SQL on the path before forwarding.
232    #[cfg(feature = "query-rewriting")]
233    rewriter: Option<Arc<crate::rewriter::QueryRewriter>>,
234    /// Multi-tenancy manager. `Some` when `[multi_tenancy] enabled`. Identifies
235    /// the tenant for a session and injects a row-level tenant filter.
236    #[cfg(feature = "multi-tenancy")]
237    tenant_manager: Option<Arc<crate::multi_tenancy::TenantManager>>,
238    /// Schema/workload query analyzer. `Some` when `[schema_routing] enabled`;
239    /// analytical (OLAP) queries are routed to the configured analytics node.
240    #[cfg(feature = "schema-routing")]
241    schema_analyzer: Option<Arc<crate::schema_routing::QueryAnalyzer>>,
242    /// Pool manager for Session/Transaction/Statement modes
243    #[cfg(feature = "pool-modes")]
244    pool_manager: Option<Arc<ConnectionPoolManager>>,
245    /// Data-path idle backend-connection pool. `Some` only when pooling is
246    /// active (mode is Transaction or Statement); `None` leaves the 1:1
247    /// session-pinned path completely unchanged. This is the raw-stream pool
248    /// the data path actually leases from, keyed by `(node, user, database)`.
249    #[cfg(feature = "pool-modes")]
250    backend_pool: Option<Arc<crate::pool::BackendIdlePool>>,
251    /// WASM plugin manager. `None` means no plugins loaded — the per-query
252    /// hook path becomes a fast no-op. When `Some`, `PreQuery` / `PostQuery`
253    /// hooks fire on every simple-query message.
254    #[cfg(feature = "wasm-plugins")]
255    plugin_manager: Option<Arc<PluginManager>>,
256    /// Shared transaction journal — single sink for per-session
257    /// statement journaling. The replay engine reads windows from
258    /// this directly. Always present when the `ha-tr` feature is on;
259    /// journaling self-disables internally when not configured.
260    #[cfg(feature = "ha-tr")]
261    transaction_journal: Arc<crate::transaction_journal::TransactionJournal>,
262    /// Anomaly detector (T3.1). Records every query and every
263    /// auth outcome; surfaces detections via /api/anomalies.
264    #[cfg(feature = "anomaly-detection")]
265    anomaly_detector: Arc<crate::anomaly::AnomalyDetector>,
266    /// Edge cache + home registry (T3.2). Both always-present even
267    /// in Home mode (the cache is a no-op there); avoids an extra
268    /// Option in the hot path.
269    #[cfg(feature = "edge-proxy")]
270    edge_cache: Arc<crate::edge::EdgeCache>,
271    #[cfg(feature = "edge-proxy")]
272    edge_registry: Arc<crate::edge::EdgeRegistry>,
273}
274
275/// Node health status
276#[derive(Debug, Clone)]
277pub struct NodeHealth {
278    /// Node address
279    pub address: String,
280    /// Whether node is healthy
281    pub healthy: bool,
282    /// Last check time
283    pub last_check: chrono::DateTime<chrono::Utc>,
284    /// Consecutive failures
285    pub failure_count: u32,
286    /// Last error message
287    pub last_error: Option<String>,
288    /// Average latency (ms)
289    pub latency_ms: f64,
290    /// Replication lag (if applicable)
291    pub replication_lag_bytes: Option<u64>,
292}
293
294/// Server metrics
295#[derive(Default)]
296struct ServerMetrics {
297    /// Total connections accepted
298    connections_accepted: AtomicU64,
299    /// Total connections closed
300    connections_closed: AtomicU64,
301    /// Total queries processed
302    queries_processed: AtomicU64,
303    /// Total bytes received from clients
304    bytes_received: AtomicU64,
305    /// Total bytes sent to clients
306    bytes_sent: AtomicU64,
307    /// Failover count
308    failovers: AtomicU64,
309}
310
311/// Load balancer state
312struct LoadBalancerState {
313    /// Round-robin counter. Atomic so the read-routing path never
314    /// takes a write lock just to advance the rotation.
315    rr_counter: AtomicU64,
316}
317
318/// Client session
319pub struct ClientSession {
320    /// Session ID
321    pub id: Uuid,
322    /// Client address
323    pub client_addr: SocketAddr,
324    /// Current backend node
325    pub current_node: RwLock<Option<String>>,
326    /// Fast, lock-free "in a transaction" flag — the single per-query hot-path
327    /// read/write of transaction state. Written from the ReadyForQuery status
328    /// byte at each response boundary; read by pool-release, read-node
329    /// selection, and cache-eligibility checks. This is authoritative on the
330    /// data path; `tx_state` (below) retains the richer structure for TR/replay
331    /// consumers but is no longer touched per query, so the relay pays no
332    /// `RwLock` acquisition just to test in-transaction.
333    pub in_transaction: std::sync::atomic::AtomicBool,
334    /// Set while the session is mid-COPY (the backend sent CopyInResponse /
335    /// CopyBothResponse and is awaiting CopyData from the client). A COPY is
336    /// NOT a clean transaction boundary even though no ReadyForQuery has been
337    /// seen yet, so Transaction/Statement pool release must be suppressed while
338    /// it is set — otherwise the connection would be reset (`DISCARD ALL`) and
339    /// parked in the middle of a copy, aborting it and hanging the client.
340    /// Cleared once the COPY drains to ReadyForQuery.
341    pub copy_in_progress: std::sync::atomic::AtomicBool,
342    /// Rich transaction state (tx id, statement log, savepoints) for
343    /// Transaction-Replay/library consumers. Not read or written on the
344    /// per-query forward path — see `in_transaction` above.
345    pub tx_state: RwLock<TransactionState>,
346    /// Session variables
347    pub variables: RwLock<HashMap<String, String>>,
348    /// Created at
349    pub created_at: chrono::DateTime<chrono::Utc>,
350    /// TR mode for this session
351    pub tr_mode: TrMode,
352    /// Wall-clock instant of this session's most recent write, for
353    /// read-your-writes routing: reads within the configured window after a
354    /// write are pinned to the primary so the client observes its own writes
355    /// despite replica lag.
356    #[cfg(feature = "lag-routing")]
357    pub last_write_at: RwLock<Option<std::time::Instant>>,
358    /// Client ID for pool-modes lease tracking
359    #[cfg(feature = "pool-modes")]
360    pub pool_client_id: ClientId,
361    /// Identity returned by an `Authenticate` plugin, if any. Downstream
362    /// plugins (masking, residency routing, cost governor) read this to
363    /// gate per-user policy. `None` when no plugin ran or every plugin
364    /// deferred to the default auth flow.
365    #[cfg(feature = "wasm-plugins")]
366    pub plugin_identity: RwLock<Option<Identity>>,
367    /// Sticky edge-cache ineligibility: set once the session executes any
368    /// statement that alters execution context (SET/SET ROLE/search_path,
369    /// temp tables, ...). The shared edge cache keys on (fingerprint,
370    /// params, db/user, startup vars) — it cannot see session-local GUC
371    /// state, so a session that mutates it must never read from or store
372    /// into the shared cache again (cross-session wrong-rows otherwise).
373    #[cfg(feature = "edge-proxy")]
374    pub edge_ineligible: std::sync::atomic::AtomicBool,
375    /// Tables of an in-flight `COPY ... FROM` awaiting its CopyDone drain.
376    /// COPY rows become visible at drain time, so the edge invalidation is
377    /// deferred until then (never held across an await).
378    #[cfg(feature = "edge-proxy")]
379    pub pending_edge_copy_tables: std::sync::Mutex<Option<Vec<String>>>,
380}
381
382/// Transaction state
383#[derive(Debug, Clone, Default)]
384pub struct TransactionState {
385    /// Whether in a transaction
386    pub in_transaction: bool,
387    /// Transaction ID
388    pub tx_id: Option<Uuid>,
389    /// Statements executed in current transaction
390    pub statements: Vec<StatementLog>,
391    /// Read-only transaction
392    pub read_only: bool,
393    /// Savepoints
394    pub savepoints: Vec<String>,
395}
396
397/// Logged statement for TR replay
398#[derive(Debug, Clone)]
399pub struct StatementLog {
400    /// Statement SQL
401    pub sql: String,
402    /// Parameters
403    pub params: Vec<String>,
404    /// Result checksum
405    pub result_checksum: Option<u64>,
406    /// Execution time
407    pub executed_at: chrono::DateTime<chrono::Utc>,
408}
409
410/// A cached per-session backend connection plus the set of *named* prepared
411/// statements known to be live on **this** socket.
412///
413/// Tying the prepared-statement set to the socket (rather than to the node
414/// address) is what makes prepared statements survive a backend switch: when a
415/// connection is dropped and redialed, or when a session is routed to a
416/// different node, the fresh `BackendConn` starts with an empty set, so the
417/// proxy transparently re-issues the original `Parse` for any named statement
418/// the target connection is missing before forwarding a `Bind`/`Describe` that
419/// references it (Batch F.4). The session keeps the canonical `Parse` bytes in
420/// a separate registry; this set is just "what does *this* socket already
421/// know".
422struct BackendConn {
423    stream: TcpStream,
424    prepared: HashSet<String>,
425    /// Signature (query text + parameter-type OIDs) of the *unnamed* prepared
426    /// statement currently established on this socket, if any. When the client
427    /// re-sends an identical unnamed `Parse`, the proxy can skip forwarding it
428    /// (the backend's unnamed statement already holds that SQL) and synthesize
429    /// the `ParseComplete` locally — the unnamed-Parse promotion (Batch H).
430    unnamed_sig: Option<bytes::Bytes>,
431    /// Whether a simple-query statement forwarded on this socket may have left
432    /// session-level state behind (a `SET`, temp table, `LISTEN`, advisory
433    /// lock, …). Used only by the conditional-reset optimisation
434    /// (`pool_mode.skip_clean_reset`): a connection is eligible to be parked
435    /// WITHOUT running the reset query only when it is provably clean —
436    /// `!dirty && prepared.is_empty() && unnamed_sig.is_none()`. Set
437    /// conservatively (any statement not provably session-neutral sets it), so
438    /// the worst outcome of a misclassification is an unnecessary reset, never
439    /// leaked state. Always `false` on a fresh/reused connection.
440    dirty: bool,
441}
442
443impl BackendConn {
444    fn new(stream: TcpStream) -> Self {
445        Self {
446            stream,
447            prepared: HashSet::new(),
448            unnamed_sig: None,
449            dirty: false,
450        }
451    }
452}
453
454/// Bind a TCP listener with `SO_REUSEADDR` + `SO_REUSEPORT` so a second process
455/// can bind the same address concurrently (the kernel then load-balances new
456/// connections across both). This is what lets a new binary take over new
457/// connections while the old one drains — used for both the client and admin
458/// listeners so a binary handoff can re-bind every address (Batch H).
459pub(crate) fn bind_reuseport(addr: &str) -> Result<TcpListener> {
460    use socket2::{Domain, Protocol, Socket, Type};
461    let sockaddr: SocketAddr = addr
462        .parse()
463        .map_err(|e| ProxyError::Config(format!("invalid listen address '{}': {}", addr, e)))?;
464    let domain = if sockaddr.is_ipv6() {
465        Domain::IPV6
466    } else {
467        Domain::IPV4
468    };
469    let socket = Socket::new(domain, Type::STREAM, Some(Protocol::TCP))
470        .map_err(|e| ProxyError::Network(format!("socket(): {}", e)))?;
471    socket
472        .set_reuse_address(true)
473        .map_err(|e| ProxyError::Network(format!("SO_REUSEADDR: {}", e)))?;
474    #[cfg(all(unix, not(target_os = "solaris")))]
475    socket
476        .set_reuse_port(true)
477        .map_err(|e| ProxyError::Network(format!("SO_REUSEPORT: {}", e)))?;
478    socket
479        .set_nonblocking(true)
480        .map_err(|e| ProxyError::Network(format!("set_nonblocking: {}", e)))?;
481    socket
482        .bind(&sockaddr.into())
483        .map_err(|e| ProxyError::Network(format!("Failed to bind {}: {}", addr, e)))?;
484    socket
485        .listen(1024)
486        .map_err(|e| ProxyError::Network(format!("listen(): {}", e)))?;
487    let std_listener: std::net::TcpListener = socket.into();
488    TcpListener::from_std(std_listener)
489        .map_err(|e| ProxyError::Network(format!("from_std listener: {}", e)))
490}
491
492/// Disposition produced by the pre-query plugin hook stage.
493///
494/// When the `wasm-plugins` feature is off, only `Forward` is ever produced —
495/// the hook dispatch is compiled out entirely and the variant list exists
496/// purely for pattern-match symmetry.
497#[derive(Debug)]
498#[allow(dead_code)] // Block/Cached only constructed under wasm-plugins
499enum PreQueryAction {
500    /// Send the message to the backend as usual.
501    Forward,
502    /// A plugin blocked the query. The caller sends an error + ReadyForQuery
503    /// to the client and skips backend forwarding.
504    Block(String),
505    /// A plugin returned a cached response. Not yet wired — response
506    /// synthesis from raw bytes requires building a full protocol reply
507    /// (RowDescription + DataRow(s) + CommandComplete + ReadyForQuery),
508    /// which is the next step of T0-a. For now the caller falls back to
509    /// `Forward` and logs a warning.
510    Cached(Vec<u8>),
511}
512
513/// Override produced by the Route plugin hook. Consumed by `route_and_forward`
514/// when deciding which backend to talk to.
515///
516/// As with `PreQueryAction`, only `None` is ever produced when the
517/// `wasm-plugins` feature is off.
518#[derive(Debug)]
519#[allow(dead_code)] // Primary/Standby/Node/Block only constructed under wasm-plugins
520enum RouteOverride {
521    /// No override — use the default SQL-verb-based routing.
522    None,
523    /// Force the write path (use `select_primary_with_timeout`).
524    Primary,
525    /// Force the read path (use `select_read_node`).
526    Standby,
527    /// Use this exact node address. Takes precedence over the is_write
528    /// heuristic; the proxy will still verify the node is healthy before
529    /// connecting (via the normal switch-vs-reuse flow).
530    Node(String),
531    /// Reject the query: write a PG ErrorResponse + ReadyForQuery to
532    /// the client and skip the forward. Carries the reason the plugin
533    /// supplied. Takes precedence over every other field — the proxy
534    /// short-circuits before any backend selection.
535    Block(String),
536}
537
538/// RAII teardown for one client connection. Its `Drop` deregisters the session
539/// from `state.sessions`, bumps the connections-closed metric, and reclaims the
540/// session's L1 query cache — running on a normal return AND on a panic unwind,
541/// so a panic in negotiation/startup/the query loop can never leak the session
542/// entry (which would inflate the active-session gauge and stall graceful
543/// drains). All operations are synchronous, so they are valid inside `Drop`.
544struct SessionGuard {
545    state: Arc<ServerState>,
546    session_id: Uuid,
547}
548
549impl Drop for SessionGuard {
550    fn drop(&mut self) {
551        self.state.sessions.remove(&self.session_id);
552        self.state
553            .metrics
554            .connections_closed
555            .fetch_add(1, Ordering::Relaxed);
556        // Reclaim the per-connection L1 query cache (keyed by the session's
557        // first u64); without this an abandoned cache leaks under churn.
558        #[cfg(feature = "query-cache")]
559        if let Some(ref qc) = self.state.query_cache {
560            qc.remove_l1_cache(self.session_id.as_u64_pair().0);
561        }
562    }
563}
564
565impl ProxyServer {
566    /// Build a `PluginManager` from config and preload plugins from disk.
567    ///
568    /// Returns `None` when plugins are disabled in config, when the
569    /// runtime fails to initialise, or when the plugin directory is
570    /// missing. Individual per-file load failures are logged but do not
571    /// abort startup — the remaining plugins load normally and the
572    /// proxy stays up.
573    #[cfg(feature = "wasm-plugins")]
574    fn init_plugin_manager(
575        toml_cfg: &crate::config::PluginToml,
576    ) -> Option<Arc<crate::plugins::PluginManager>> {
577        if !toml_cfg.enabled {
578            return None;
579        }
580
581        let runtime_cfg = crate::plugins::PluginRuntimeConfig::from(toml_cfg);
582        let plugin_dir = runtime_cfg.plugin_dir.clone();
583
584        let pm = match crate::plugins::PluginManager::new(runtime_cfg) {
585            Ok(pm) => Arc::new(pm),
586            Err(e) => {
587                tracing::error!(error = %e, "Failed to create plugin manager; plugins disabled");
588                return None;
589            }
590        };
591
592        match std::fs::read_dir(&plugin_dir) {
593            Ok(entries) => {
594                let mut loaded = 0usize;
595                let mut failed = 0usize;
596                for entry in entries.flatten() {
597                    let path = entry.path();
598                    if path.extension().and_then(|s| s.to_str()) != Some("wasm") {
599                        continue;
600                    }
601                    match pm.load_plugin(&path) {
602                        Ok(()) => loaded += 1,
603                        Err(e) => {
604                            failed += 1;
605                            tracing::warn!(
606                                path = %path.display(),
607                                error = %e,
608                                "Failed to load plugin"
609                            );
610                        }
611                    }
612                }
613                tracing::info!(
614                    dir = %plugin_dir.display(),
615                    loaded = loaded,
616                    failed = failed,
617                    "Plugin loading complete"
618                );
619            }
620            Err(e) => {
621                tracing::warn!(
622                    dir = %plugin_dir.display(),
623                    error = %e,
624                    "Plugin directory not readable; no plugins loaded"
625                );
626            }
627        }
628
629        Some(pm)
630    }
631
632    /// Create a new proxy server
633    pub fn new(config: ProxyConfig) -> Result<Self> {
634        let (shutdown_tx, _) = broadcast::channel(1);
635
636        // Initialize health status
637        let mut health = HashMap::new();
638        for node in &config.nodes {
639            health.insert(
640                node.address(),
641                NodeHealth {
642                    address: node.address(),
643                    healthy: true, // Assume healthy until proven otherwise
644                    last_check: chrono::Utc::now(),
645                    failure_count: 0,
646                    last_error: None,
647                    latency_ms: 0.0,
648                    replication_lag_bytes: None,
649                },
650            );
651        }
652
653        // Initialize pool manager if pool-modes feature is enabled
654        #[cfg(feature = "pool-modes")]
655        let pool_manager = {
656            use crate::pool::PreparedStatementMode as PoolPreparedStatementMode;
657
658            let pool_config = PoolModeConfig {
659                default_mode: match config.pool_mode.mode {
660                    crate::config::PoolingMode::Session => PoolingMode::Session,
661                    crate::config::PoolingMode::Transaction => PoolingMode::Transaction,
662                    crate::config::PoolingMode::Statement => PoolingMode::Statement,
663                },
664                max_pool_size: config.pool_mode.max_pool_size,
665                min_idle: config.pool_mode.min_idle,
666                idle_timeout_secs: config.pool_mode.idle_timeout_secs,
667                max_lifetime_secs: config.pool_mode.max_lifetime_secs,
668                acquire_timeout_secs: config.pool_mode.acquire_timeout_secs,
669                reset_query: config.pool_mode.reset_query.clone(),
670                prepared_statement_mode: match config.pool_mode.prepared_statement_mode {
671                    crate::config::PreparedStatementMode::Disable => {
672                        PoolPreparedStatementMode::Disable
673                    }
674                    crate::config::PreparedStatementMode::Track => PoolPreparedStatementMode::Track,
675                    crate::config::PreparedStatementMode::Named => PoolPreparedStatementMode::Named,
676                },
677                test_on_acquire: config.pool.test_on_acquire,
678                validation_query: "SELECT 1".to_string(),
679                queue_timeout_secs: 30,
680                max_queue_size: 0,
681            };
682            Some(Arc::new(ConnectionPoolManager::new(pool_config)))
683        };
684
685        // The raw-stream data-path pool is only built when pooling is active
686        // (Transaction/Statement). Session mode leaves it `None` so the hot
687        // path is byte-for-byte unchanged.
688        #[cfg(feature = "pool-modes")]
689        let backend_pool = match config.pool_mode.mode {
690            crate::config::PoolingMode::Transaction | crate::config::PoolingMode::Statement => {
691                tracing::info!(
692                    mode = ?config.pool_mode.mode,
693                    max_idle_per_identity = config.pool_mode.max_pool_size,
694                    "pool-modes: data-path connection pooling enabled"
695                );
696                Some(Arc::new(crate::pool::BackendIdlePool::new(
697                    config.pool_mode.max_pool_size as usize,
698                    Self::MAX_TOTAL_IDLE_BACKEND_CONNS,
699                )))
700            }
701            crate::config::PoolingMode::Session => None,
702        };
703
704        // Initialize plugin manager if the wasm-plugins feature is enabled
705        // AND plugins are turned on in config. Scans plugin_dir for `.wasm`
706        // files and loads each; a missing directory is non-fatal and logs
707        // a warning so empty deployments don't fail startup.
708        #[cfg(feature = "wasm-plugins")]
709        let plugin_manager = Self::init_plugin_manager(&config.plugins);
710
711        // Build the client TLS acceptor if [tls] is configured + enabled.
712        // A bad cert/key is fatal at startup (fail fast, don't silently
713        // fall back to plaintext for a deployment that asked for TLS).
714        let tls_acceptor = match config.tls.as_ref() {
715            Some(tls) if tls.enabled => match build_tls_acceptor(tls) {
716                Ok(acc) => {
717                    tracing::info!(
718                        mtls = tls.require_client_cert,
719                        "client TLS termination enabled"
720                    );
721                    Some(acc)
722                }
723                Err(e) => {
724                    return Err(ProxyError::Config(format!("TLS init failed: {}", e)));
725                }
726            },
727            _ => None,
728        };
729
730        // Load the SCRAM auth_file when proxy-terminated auth is requested.
731        // Misconfiguration is fatal at startup (fail fast).
732        let auth_file = if config.auth.mode == crate::config::AuthMode::Scram {
733            let path = config.auth.auth_file.as_ref().ok_or_else(|| {
734                ProxyError::Config("auth mode 'scram' requires auth_file".to_string())
735            })?;
736            let af = crate::auth_scram::AuthFile::load(path)
737                .map_err(|e| ProxyError::Config(format!("auth_file: {}", e)))?;
738            tracing::info!(users = %(!af.is_empty()), "proxy SCRAM auth enabled");
739            Some(Arc::new(af))
740        } else {
741            None
742        };
743
744        // Spawn the traffic-mirror worker when enabled (we are inside the
745        // tokio runtime here — main is #[tokio::main]).
746        let mirror = if config.mirror.enabled {
747            tracing::info!(target = %format!("{}:{}", config.mirror.backend_host, config.mirror.backend_port),
748                writes_only = config.mirror.writes_only, "traffic mirroring enabled");
749            Some(crate::mirror::spawn(config.mirror.clone()))
750        } else {
751            None
752        };
753
754        // Build the rate limiter from the TOML config when enabled.
755        #[cfg(feature = "rate-limiting")]
756        let rate_limiter = if config.rate_limit.enabled {
757            let rl = &config.rate_limit;
758            tracing::info!(
759                qps = rl.default_qps,
760                burst = rl.default_burst,
761                key_by = ?rl.key_by,
762                "rate limiting enabled"
763            );
764            let rlc = crate::rate_limit::RateLimitConfig {
765                enabled: true,
766                default_qps: rl.default_qps,
767                default_burst: rl.default_burst,
768                default_concurrency: if rl.max_concurrent > 0 {
769                    rl.max_concurrent
770                } else {
771                    crate::rate_limit::RateLimitConfig::default().default_concurrency
772                },
773                ..Default::default()
774            };
775            Some(Arc::new(crate::rate_limit::RateLimiter::new(rlc)))
776        } else {
777            None
778        };
779
780        // Build the per-node circuit breaker manager when enabled.
781        #[cfg(feature = "circuit-breaker")]
782        let circuit_breaker = if config.circuit_breaker.enabled {
783            let cb = &config.circuit_breaker;
784            tracing::info!(
785                failure_threshold = cb.failure_threshold,
786                open_secs = cb.open_secs,
787                "circuit breaker enabled"
788            );
789            let cbc = crate::circuit_breaker::CircuitBreakerConfig {
790                failure_threshold: cb.failure_threshold,
791                cooldown: Duration::from_secs(cb.open_secs),
792                half_open_success_threshold: cb.success_threshold,
793                ..Default::default()
794            };
795            let mgr = crate::circuit_breaker::CircuitBreakerManager::new(
796                crate::circuit_breaker::ManagerConfig::new(cbc),
797            );
798            Some(Arc::new(mgr))
799        } else {
800            None
801        };
802
803        // Build the query-analytics engine when enabled.
804        #[cfg(feature = "query-analytics")]
805        let analytics = if config.analytics.enabled {
806            let a = &config.analytics;
807            tracing::info!(
808                slow_query_ms = a.slow_query_ms,
809                max_fingerprints = a.max_fingerprints,
810                "query analytics enabled"
811            );
812            let ac = crate::analytics::AnalyticsConfig {
813                enabled: true,
814                max_fingerprints: a.max_fingerprints as usize,
815                slow_query: crate::analytics::SlowQueryConfig {
816                    threshold: Duration::from_millis(a.slow_query_ms),
817                    ..Default::default()
818                },
819                ..Default::default()
820            };
821            Some(Arc::new(crate::analytics::QueryAnalytics::new(ac)))
822        } else {
823            None
824        };
825
826        // Build the query-result cache when enabled.
827        #[cfg(feature = "query-cache")]
828        let query_cache = if config.cache.enabled {
829            let c = &config.cache;
830            tracing::info!(
831                ttl_secs = c.ttl_secs,
832                max_result_bytes = c.max_result_bytes,
833                "query cache enabled (L1 hot + L2 warm)"
834            );
835            let ttl = Duration::from_secs(c.ttl_secs);
836            let cc = crate::cache::CacheConfig {
837                enabled: true,
838                default_ttl: ttl,
839                max_result_size: c.max_result_bytes,
840                l1: crate::cache::L1Config {
841                    ttl,
842                    ..Default::default()
843                },
844                l2: crate::cache::L2Config {
845                    ttl,
846                    ..Default::default()
847                },
848                ..Default::default()
849            };
850            Some(Arc::new(crate::cache::QueryCache::new(cc)))
851        } else {
852            None
853        };
854
855        // Build the SQL query rewriter from the configured rules.
856        #[cfg(feature = "query-rewriting")]
857        let rewriter = if config.query_rewrite.enabled && !config.query_rewrite.rules.is_empty() {
858            use crate::rewriter::{
859                QueryPattern, QueryRewriter, RewriteRule, RewriterConfig, Transformation,
860            };
861            let rw = QueryRewriter::new(RewriterConfig {
862                enabled: true,
863                ..Default::default()
864            });
865            let mut n = 0usize;
866            for (i, r) in config.query_rewrite.rules.iter().enumerate() {
867                let transformation =
868                    if let (Some(from), Some(to)) = (&r.match_table, &r.replace_table_with) {
869                        Transformation::ReplaceTable {
870                            from: from.clone(),
871                            to: to.clone(),
872                        }
873                    } else if let Some(w) = &r.append_where {
874                        Transformation::AppendWhereAnd(w.clone())
875                    } else if let Some(limit) = r.add_limit {
876                        Transformation::AddLimit(limit)
877                    } else {
878                        continue; // no transformation specified — skip
879                    };
880                let pattern = if let Some(t) = &r.match_table {
881                    QueryPattern::Table(t.clone())
882                } else if let Some(re) = &r.match_regex {
883                    QueryPattern::regex(re.clone())
884                } else {
885                    QueryPattern::All
886                };
887                rw.add_rule(
888                    RewriteRule::build(format!("rule-{i}"))
889                        .pattern(pattern)
890                        .transform(transformation)
891                        .build(),
892                );
893                n += 1;
894            }
895            tracing::info!(rules = n, "query rewriting enabled");
896            Some(Arc::new(rw))
897        } else {
898            None
899        };
900
901        // Build the multi-tenancy manager from the configured tenants.
902        #[cfg(feature = "multi-tenancy")]
903        let tenant_manager =
904            if config.multi_tenancy.enabled && !config.multi_tenancy.tenants.is_empty() {
905                use crate::multi_tenancy::{
906                    IdentificationMethod, IsolationStrategy, MultiTenancyConfig, TenantConfig,
907                    TenantId, TenantManagerBuilder, TenantQueryTransformer,
908                };
909                let mt = &config.multi_tenancy;
910                let identification = match mt.identify_by.as_str() {
911                    "database" => IdentificationMethod::DatabaseName,
912                    param => IdentificationMethod::Header {
913                        header_name: param.to_string(),
914                    },
915                };
916                let mtc = MultiTenancyConfig {
917                    enabled: true,
918                    identification,
919                    ..Default::default()
920                };
921                // Configure which tables are tenant-scoped + the filter column.
922                let table_refs: Vec<&str> = mt.tenant_tables.iter().map(|s| s.as_str()).collect();
923                let transformer = TenantQueryTransformer::new()
924                    .register_tables(&table_refs, mt.tenant_column.clone());
925                let tm = TenantManagerBuilder::new()
926                    .config(mtc)
927                    .query_transformer(transformer)
928                    .build();
929                for id in &mt.tenants {
930                    tm.register_tenant(TenantConfig::new(
931                        TenantId::new(id.clone()),
932                        IsolationStrategy::row("public", mt.tenant_column.clone()),
933                    ));
934                }
935                tracing::info!(
936                    tenants = mt.tenants.len(),
937                    identify_by = %mt.identify_by,
938                    "multi-tenancy enabled"
939                );
940                Some(Arc::new(tm))
941            } else {
942                None
943            };
944
945        // Build the schema/workload query analyzer when enabled.
946        #[cfg(feature = "schema-routing")]
947        let schema_analyzer =
948            if config.schema_routing.enabled && !config.schema_routing.analytics_node.is_empty() {
949                tracing::info!(
950                    analytics_node = %config.schema_routing.analytics_node,
951                    "schema/workload routing enabled (OLAP -> analytics node)"
952                );
953                let registry = Arc::new(crate::schema_routing::SchemaRegistry::new());
954                Some(Arc::new(crate::schema_routing::QueryAnalyzer::new(
955                    registry,
956                )))
957            } else {
958                None
959            };
960
961        let state = Arc::new(ServerState {
962            sessions: DashMap::new(),
963            health: ArcSwap::from_pointee(health),
964            health_write: parking_lot::Mutex::new(()),
965            live_config: ArcSwap::from_pointee(config.clone()),
966            metrics: ServerMetrics::default(),
967            cancel_map: Arc::new(DashMap::new()),
968            cancel_order: Arc::new(parking_lot::Mutex::new(std::collections::VecDeque::new())),
969            tls_acceptor,
970            auth_file,
971            mirror,
972            cutover: Arc::new(ArcSwap::from_pointee(None)),
973            lb_state: LoadBalancerState {
974                rr_counter: AtomicU64::new(0),
975            },
976            #[cfg(feature = "routing-hints")]
977            hint_parser: if config.routing_hints.enabled {
978                tracing::info!(
979                    strip = config.routing_hints.strip_hints,
980                    "SQL-comment routing hints enabled"
981                );
982                Some(if config.routing_hints.strip_hints {
983                    crate::routing::HintParser::new()
984                } else {
985                    crate::routing::HintParser::without_stripping()
986                })
987            } else {
988                None
989            },
990            #[cfg(feature = "rate-limiting")]
991            rate_limiter,
992            #[cfg(feature = "circuit-breaker")]
993            circuit_breaker,
994            #[cfg(feature = "query-analytics")]
995            analytics,
996            #[cfg(feature = "query-cache")]
997            query_cache,
998            #[cfg(feature = "query-rewriting")]
999            rewriter,
1000            #[cfg(feature = "multi-tenancy")]
1001            tenant_manager,
1002            #[cfg(feature = "schema-routing")]
1003            schema_analyzer,
1004            #[cfg(feature = "pool-modes")]
1005            pool_manager,
1006            #[cfg(feature = "pool-modes")]
1007            backend_pool,
1008            #[cfg(feature = "wasm-plugins")]
1009            plugin_manager,
1010            #[cfg(feature = "ha-tr")]
1011            transaction_journal: Arc::new(crate::transaction_journal::TransactionJournal::new()),
1012            #[cfg(feature = "anomaly-detection")]
1013            anomaly_detector: Arc::new(crate::anomaly::AnomalyDetector::new(
1014                crate::anomaly::AnomalyConfig::default(),
1015            )),
1016            #[cfg(feature = "edge-proxy")]
1017            edge_cache: Arc::new(crate::edge::EdgeCache::new(config.edge.max_entries.max(1))),
1018            #[cfg(feature = "edge-proxy")]
1019            edge_registry: Arc::new(crate::edge::EdgeRegistry::new(
1020                config.edge.max_edges,
1021                std::time::Duration::from_secs(config.edge.liveness_window_secs),
1022            )),
1023        });
1024
1025        Ok(Self {
1026            config,
1027            state,
1028            shutdown_tx,
1029            config_path: None,
1030        })
1031    }
1032
1033    /// Record the config file path so `SIGHUP` can re-read it for a live
1034    /// reload (Batch H). Without a path (config built from CLI flags/defaults)
1035    /// a `SIGHUP` is logged and ignored — there is nothing to re-read.
1036    pub fn with_config_path(mut self, path: Option<String>) -> Self {
1037        self.config_path = path;
1038        self
1039    }
1040
1041    /// A stream that yields once per `SIGHUP`. On non-Unix platforms it never
1042    /// yields (config reload is Unix-signal driven).
1043    #[cfg(unix)]
1044    fn hangup_stream() -> tokio::signal::unix::Signal {
1045        tokio::signal::unix::signal(tokio::signal::unix::SignalKind::hangup())
1046            .expect("failed to install SIGHUP handler")
1047    }
1048    #[cfg(not(unix))]
1049    fn hangup_stream() -> HangupNever {
1050        HangupNever
1051    }
1052
1053    /// A stream that yields once per `SIGUSR2` — the graceful binary-handoff
1054    /// drain trigger. Never yields on non-Unix platforms.
1055    #[cfg(unix)]
1056    fn usr2_stream() -> tokio::signal::unix::Signal {
1057        tokio::signal::unix::signal(tokio::signal::unix::SignalKind::user_defined2())
1058            .expect("failed to install SIGUSR2 handler")
1059    }
1060    #[cfg(not(unix))]
1061    fn usr2_stream() -> HangupNever {
1062        HangupNever
1063    }
1064
1065    /// Wait for in-flight client connections to finish, up to `timeout`. Used by
1066    /// the graceful drain after the listener is closed — the session map is the
1067    /// live active-connection gauge (one entry per accepted connection).
1068    async fn drain_connections(state: &Arc<ServerState>, timeout: Duration) {
1069        let deadline = tokio::time::Instant::now() + timeout;
1070        loop {
1071            let active = state.sessions.len();
1072            if active == 0 {
1073                tracing::info!("drain complete — all in-flight connections finished");
1074                return;
1075            }
1076            if tokio::time::Instant::now() >= deadline {
1077                tracing::warn!(
1078                    active,
1079                    "drain timeout reached — exiting with connections still open"
1080                );
1081                return;
1082            }
1083            tokio::time::sleep(Duration::from_millis(200)).await;
1084        }
1085    }
1086
1087    /// Graceful-drain timeout: how long to keep serving in-flight connections
1088    /// after SIGUSR2 before exiting. Sourced from `shutdown_drain_timeout_secs`
1089    /// in the live config, with the `HELIOS_DRAIN_TIMEOUT_SECS` env var as a
1090    /// runtime override.
1091    fn drain_timeout(config_secs: u64) -> Duration {
1092        let secs = std::env::var("HELIOS_DRAIN_TIMEOUT_SECS")
1093            .ok()
1094            .and_then(|s| s.parse::<u64>().ok())
1095            .unwrap_or(config_secs);
1096        Duration::from_secs(secs)
1097    }
1098
1099    /// Re-read the config file and hot-swap the live config (Batch H).
1100    ///
1101    /// New connections immediately use the reloaded config; in-flight sessions
1102    /// keep the snapshot they began with, so nothing is dropped. A parse error
1103    /// keeps the running config untouched. Socket-bound fields (listen/admin
1104    /// address) cannot change on an already-bound listener and are reported but
1105    /// not applied. The node set is reconciled into the health map so routing
1106    /// sees additions/removals at once.
1107    async fn reload_config(&self) {
1108        let Some(path) = self.config_path.as_deref() else {
1109            tracing::warn!(
1110                "SIGHUP received but config was not loaded from a file — nothing to reload"
1111            );
1112            return;
1113        };
1114        tracing::info!(path, "SIGHUP: reloading configuration");
1115        let mut new_config = match ProxyConfig::from_file(path) {
1116            Ok(c) => c,
1117            Err(e) => {
1118                tracing::error!(path, error = %e, "SIGHUP reload failed to parse — keeping current config");
1119                return;
1120            }
1121        };
1122        let old = self.state.live_config.load_full();
1123        // [edge] is applied at startup only: the EdgeClient subscription and
1124        // the registry GC task are spawned once in run() from the boot
1125        // config. Letting the per-connection data path follow a reloaded
1126        // [edge] would half-enable the feature (e.g. role=edge serving
1127        // cached reads with no invalidation subscription ever spawned), so
1128        // carry the running section forward — same warn-and-keep treatment
1129        // as listen_address/admin_address.
1130        if new_config.edge != old.edge {
1131            tracing::warn!(
1132                "[edge] configuration changed on SIGHUP but is applied at startup only — \
1133                 keeping the running edge settings (restart to apply)"
1134            );
1135            new_config.edge = old.edge.clone();
1136        }
1137        if new_config.listen_address != old.listen_address {
1138            tracing::warn!(old = %old.listen_address, new = %new_config.listen_address,
1139                "listen_address change needs a restart/handoff; the bound socket is kept");
1140        }
1141        if new_config.admin_address != old.admin_address {
1142            tracing::warn!(old = %old.admin_address, new = %new_config.admin_address,
1143                "admin_address change needs a restart; the bound socket is kept");
1144        }
1145        // Reconcile node health to the new node set before publishing the
1146        // config, so the first connection on the new config can route to it.
1147        Self::reconcile_health(&self.state, &new_config);
1148        let nodes = new_config.nodes.len();
1149        let hba_rules = new_config.hba.len();
1150        let pool_max = new_config.pool.max_connections;
1151        self.state.live_config.store(Arc::new(new_config));
1152        tracing::info!(
1153            nodes,
1154            hba_rules,
1155            pool_max,
1156            "SIGHUP: configuration reloaded — applies to new connections"
1157        );
1158    }
1159
1160    /// Rebuild the health map for `config`'s node set: surviving nodes keep
1161    /// their current health; new nodes are seeded healthy (immediately
1162    /// routable, the next check confirms); removed nodes are dropped.
1163    fn reconcile_health(state: &Arc<ServerState>, config: &ProxyConfig) {
1164        // Serialize against the periodic checker and in-band demotions so this
1165        // rebuild neither clobbers nor is clobbered by a concurrent write.
1166        let _writers = state.health_write.lock();
1167        let current = state.health.load_full();
1168        let mut next: HashMap<String, NodeHealth> = HashMap::new();
1169        for node in &config.nodes {
1170            let addr = node.address();
1171            match current.get(&addr) {
1172                Some(existing) => {
1173                    next.insert(addr, existing.clone());
1174                }
1175                None => {
1176                    tracing::info!(node = %addr, "SIGHUP: new node added — seeding healthy");
1177                    next.insert(
1178                        addr.clone(),
1179                        NodeHealth {
1180                            address: addr,
1181                            healthy: true,
1182                            last_check: chrono::Utc::now(),
1183                            failure_count: 0,
1184                            last_error: None,
1185                            latency_ms: 0.0,
1186                            replication_lag_bytes: None,
1187                        },
1188                    );
1189                }
1190            }
1191        }
1192        for gone in current.keys().filter(|k| !next.contains_key(*k)) {
1193            tracing::info!(node = %gone, "SIGHUP: node removed from config");
1194        }
1195        state.health.store(Arc::new(next));
1196    }
1197
1198    /// Run the proxy server
1199    pub async fn run(&self) -> Result<()> {
1200        // Bind with SO_REUSEPORT so a freshly-started binary can bind the SAME
1201        // listen address concurrently — the kernel load-balances new
1202        // connections across both processes. That is the mechanism behind the
1203        // zero-downtime binary handoff: start the new binary, then SIGUSR2 the
1204        // old one to close its listener and drain (Batch H, item 84).
1205        let listener = bind_reuseport(&self.config.listen_address)?;
1206
1207        tracing::info!(
1208            "Proxy listening on {} (SO_REUSEPORT)",
1209            self.config.listen_address
1210        );
1211
1212        // Start background tasks
1213        let health_task = self.spawn_health_checker();
1214        let pool_task = self.spawn_pool_manager();
1215
1216        // Edge registry GC — prunes edges not seen within the liveness window.
1217        #[cfg(feature = "edge-proxy")]
1218        let _edge_maintenance_task = if self.config.edge.enabled {
1219            Some(self.spawn_edge_maintenance())
1220        } else {
1221            None
1222        };
1223
1224        // Start admin server
1225        let admin_task = self.spawn_admin_server();
1226
1227        // Start the MCP agent gateway when enabled.
1228        let mcp_task = if self.config.mcp.enabled {
1229            let mcp_cfg = self.config.mcp.clone();
1230            // Resolve the configured agent contract (scoped grants) by id.
1231            let contract = mcp_cfg.contract.as_ref().and_then(|id| {
1232                let found = self.config.agent_contracts.iter().find(|c| &c.id == id).cloned();
1233                if found.is_none() {
1234                    tracing::warn!(%id, "mcp.contract names an unknown agent_contract; gateway runs with only the read-only guardrail");
1235                }
1236                found
1237            });
1238            Some(tokio::spawn(async move {
1239                if let Err(e) = crate::mcp::McpServer::new(mcp_cfg, contract).run().await {
1240                    tracing::error!("MCP gateway error: {}", e);
1241                }
1242            }))
1243        } else {
1244            None
1245        };
1246
1247        // Start the HTTP SQL gateway (Neon-serverless compatible) when enabled.
1248        let http_gw_task = if self.config.http_gateway.enabled {
1249            let gw_cfg = self.config.http_gateway.clone();
1250            Some(tokio::spawn(async move {
1251                if let Err(e) = crate::http_gateway::HttpGateway::new(gw_cfg).run().await {
1252                    tracing::error!("HTTP gateway error: {}", e);
1253                }
1254            }))
1255        } else {
1256            None
1257        };
1258
1259        // Start the GraphQL-to-SQL gateway when enabled.
1260        #[cfg(feature = "graphql-gateway")]
1261        let _graphql_gw_task = if self.config.graphql_gateway.enabled {
1262            let gw_cfg = self.config.graphql_gateway.clone();
1263            Some(tokio::spawn(async move {
1264                if let Err(e) = crate::graphql_gateway::GraphqlGateway::new(gw_cfg)
1265                    .run()
1266                    .await
1267                {
1268                    tracing::error!("GraphQL gateway error: {}", e);
1269                }
1270            }))
1271        } else {
1272            None
1273        };
1274
1275        // Edge role: hold an SSE subscription against the home's admin API so
1276        // invalidations drop matching local cache entries as they arrive.
1277        #[cfg(feature = "edge-proxy")]
1278        let _edge_client_task =
1279            if self.config.edge.enabled && self.config.edge.role == crate::edge::EdgeRole::Edge {
1280                tracing::info!(
1281                    home_url = %self.config.edge.home_url,
1282                    region = %self.config.edge.region,
1283                    "edge role: subscribing to home invalidation stream"
1284                );
1285                Some(crate::edge::client::EdgeClient::spawn(
1286                    self.config.edge.clone(),
1287                    self.state.edge_cache.clone(),
1288                ))
1289            } else {
1290                None
1291            };
1292
1293        let mut shutdown_rx = self.shutdown_tx.subscribe();
1294
1295        // SIGHUP -> zero-downtime config reload; SIGUSR2 -> graceful drain for
1296        // binary handoff (Batch H). On platforms without Unix signals these are
1297        // simply never readable.
1298        let mut sighup = Self::hangup_stream();
1299        let mut sigusr2 = Self::usr2_stream();
1300        let mut graceful = false;
1301
1302        loop {
1303            tokio::select! {
1304                _ = sighup.recv() => {
1305                    self.reload_config().await;
1306                }
1307                _ = sigusr2.recv() => {
1308                    tracing::info!(
1309                        "SIGUSR2: graceful binary-handoff drain — closing the listener so new \
1310                         connections route to the sibling process; finishing in-flight connections"
1311                    );
1312                    graceful = true;
1313                    break;
1314                }
1315                accept_result = listener.accept() => {
1316                    match accept_result {
1317                        Ok((stream, addr)) => {
1318                            // PG wire traffic is small request/response
1319                            // frames; Nagle + delayed-ACK costs tens of
1320                            // ms per round-trip if left on.
1321                            let _ = stream.set_nodelay(true);
1322                            self.state.metrics.connections_accepted.fetch_add(1, Ordering::Relaxed);
1323                            let state = self.state.clone();
1324                            // Snapshot the *live* config so a SIGHUP reload
1325                            // applies to new connections; in-flight sessions
1326                            // keep the snapshot they began with (Batch H).
1327                            let config = (*self.state.live_config.load_full()).clone();
1328                            let shutdown_tx = self.shutdown_tx.clone();
1329
1330                            tokio::spawn(async move {
1331                                if let Err(e) = Self::handle_client(stream, addr, state, config, shutdown_tx).await {
1332                                    tracing::error!("Client handler error: {}", e);
1333                                }
1334                            });
1335                        }
1336                        Err(e) => {
1337                            tracing::error!("Accept error: {}", e);
1338                        }
1339                    }
1340                }
1341                _ = shutdown_rx.recv() => {
1342                    tracing::info!("Shutdown signal received");
1343                    break;
1344                }
1345            }
1346        }
1347
1348        // Close the listening socket so the kernel stops routing new connections
1349        // to this process's accept queue (with SO_REUSEPORT they would otherwise
1350        // sit unaccepted) — all new connections now go to the sibling listener.
1351        drop(listener);
1352
1353        // On a graceful handoff, keep serving in-flight connections until they
1354        // finish (or the drain deadline), so nothing in flight is dropped.
1355        if graceful {
1356            let timeout =
1357                Self::drain_timeout(self.state.live_config.load().shutdown_drain_timeout_secs);
1358            tracing::info!(
1359                timeout_secs = timeout.as_secs(),
1360                "draining in-flight connections"
1361            );
1362            Self::drain_connections(&self.state, timeout).await;
1363        }
1364
1365        // Wait for background tasks
1366        health_task.abort();
1367        pool_task.abort();
1368        admin_task.abort();
1369        if let Some(t) = mcp_task {
1370            t.abort();
1371        }
1372        if let Some(t) = http_gw_task {
1373            t.abort();
1374        }
1375
1376        Ok(())
1377    }
1378
1379    /// Spawn admin API server
1380    fn spawn_admin_server(&self) -> tokio::task::JoinHandle<()> {
1381        let config = self.config.clone();
1382        let state = self.state.clone();
1383        let mut shutdown_rx = self.shutdown_tx.subscribe();
1384
1385        tokio::spawn(async move {
1386            // Create admin state
1387            let admin_state = Arc::new(AdminState::new());
1388
1389            // Initialize config snapshot
1390            {
1391                let mut snapshot = admin_state.config_snapshot.write().await;
1392                *snapshot = ConfigSnapshot {
1393                    listen_address: config.listen_address.clone(),
1394                    admin_address: config.admin_address.clone(),
1395                    tr_enabled: config.tr_enabled,
1396                    tr_mode: format!("{:?}", config.tr_mode),
1397                    pool_min_connections: config.pool.min_connections,
1398                    pool_max_connections: config.pool.max_connections,
1399                    nodes: config
1400                        .nodes
1401                        .iter()
1402                        .map(|n| NodeSnapshot {
1403                            address: n.address(),
1404                            role: format!("{:?}", n.role),
1405                            weight: n.weight,
1406                            enabled: n.enabled,
1407                        })
1408                        .collect(),
1409                };
1410            }
1411
1412            // Set proxy config for SQL routing
1413            admin_state.set_proxy_config(config.clone()).await;
1414
1415            // Require a Bearer token on admin requests when configured.
1416            admin_state
1417                .with_auth_token(config.admin_token.clone())
1418                .await;
1419
1420            // Branch-database provisioning surface.
1421            if config.branch.enabled {
1422                admin_state.with_branch(config.branch.clone()).await;
1423            }
1424
1425            // Surface traffic-mirror / migration status when mirroring is on.
1426            if let Some(ref mirror) = state.mirror {
1427                admin_state
1428                    .with_migration(crate::admin::MigrationInfo {
1429                        target: mirror.target().to_string(),
1430                        writes_only: mirror.writes_only(),
1431                        metrics: mirror.metrics.clone(),
1432                        config: config.mirror.clone(),
1433                        cutover: state.cutover.clone(),
1434                        cutover_target: crate::mirror::CutoverTarget {
1435                            addr: format!(
1436                                "{}:{}",
1437                                config.mirror.backend_host, config.mirror.backend_port
1438                            ),
1439                            user: config.mirror.backend_user.clone(),
1440                            password: config.mirror.backend_password.clone(),
1441                            database: config.mirror.backend_database.clone(),
1442                        },
1443                    })
1444                    .await;
1445            }
1446
1447            // Attach the plugin manager so /plugins + the admin UI
1448            // surface real loaded modules. Cheap Arc-clone — no
1449            // duplicate state, both AdminState and ServerState hold
1450            // the same manager.
1451            #[cfg(feature = "wasm-plugins")]
1452            if let Some(ref pm) = state.plugin_manager {
1453                admin_state.with_plugin_manager(pm.clone()).await;
1454            }
1455
1456            // Attach the pool manager so /api/pools surfaces real per-node
1457            // pool statistics instead of an empty list.
1458            #[cfg(feature = "pool-modes")]
1459            if let Some(ref pm) = state.pool_manager {
1460                admin_state.with_pool_manager(pm.clone()).await;
1461            }
1462
1463            // Attach the circuit-breaker manager so /api/circuit reports live
1464            // per-node breaker state.
1465            #[cfg(feature = "circuit-breaker")]
1466            if let Some(ref cb) = state.circuit_breaker {
1467                admin_state.with_circuit_breaker(cb.clone()).await;
1468            }
1469
1470            // Attach the time-travel replay engine. The engine reads
1471            // windows from the shared TransactionJournal and replays
1472            // statements against a target backend supplied per-request.
1473            // Per-call credential overrides land via FU-21's
1474            // ReplayRequestBody.target_user / target_password /
1475            // target_database fields.
1476            #[cfg(feature = "ha-tr")]
1477            {
1478                let template = build_replay_backend_template(&config);
1479                let engine = Arc::new(crate::replay::ReplayEngine::new(
1480                    state.transaction_journal.clone(),
1481                    template,
1482                ));
1483                admin_state.with_replay_engine(engine).await;
1484            }
1485
1486            // Attach the anomaly detector — same Arc the server
1487            // populates from the query path. /api/anomalies polls
1488            // this for surfaced detections.
1489            #[cfg(feature = "anomaly-detection")]
1490            admin_state
1491                .with_anomaly_detector(state.anomaly_detector.clone())
1492                .await;
1493
1494            // Attach the query-analytics engine so /api/analytics can read it.
1495            #[cfg(feature = "query-analytics")]
1496            if let Some(a) = state.analytics.as_ref() {
1497                admin_state.with_analytics(a.clone()).await;
1498            }
1499
1500            // Attach the edge cache + registry. Both surfaced via
1501            // /api/edge/* admin routes.
1502            #[cfg(feature = "edge-proxy")]
1503            admin_state
1504                .with_edge(state.edge_cache.clone(), state.edge_registry.clone())
1505                .await;
1506
1507            // Create admin server
1508            let admin_server = AdminServer::new(config.admin_address.clone(), admin_state.clone());
1509
1510            // Spawn state sync task
1511            let admin_state_sync = admin_state.clone();
1512            let server_state = state.clone();
1513            let sync_task = tokio::spawn(async move {
1514                let mut interval = tokio::time::interval(std::time::Duration::from_secs(1));
1515                loop {
1516                    interval.tick().await;
1517
1518                    // Sync health status
1519                    {
1520                        let health = server_state.health.load_full();
1521                        let mut admin_health = admin_state_sync.node_health.write().await;
1522                        *admin_health = (*health).clone();
1523                    }
1524
1525                    // Sync metrics
1526                    {
1527                        let metrics = ServerMetricsSnapshot {
1528                            connections_accepted: server_state
1529                                .metrics
1530                                .connections_accepted
1531                                .load(Ordering::Relaxed),
1532                            connections_closed: server_state
1533                                .metrics
1534                                .connections_closed
1535                                .load(Ordering::Relaxed),
1536                            queries_processed: server_state
1537                                .metrics
1538                                .queries_processed
1539                                .load(Ordering::Relaxed),
1540                            bytes_received: server_state
1541                                .metrics
1542                                .bytes_received
1543                                .load(Ordering::Relaxed),
1544                            bytes_sent: server_state.metrics.bytes_sent.load(Ordering::Relaxed),
1545                            failovers: server_state.metrics.failovers.load(Ordering::Relaxed),
1546                        };
1547                        let mut admin_metrics = admin_state_sync.metrics.write().await;
1548                        *admin_metrics = metrics;
1549                    }
1550
1551                    // Sync session count
1552                    {
1553                        let mut admin_sessions = admin_state_sync.active_sessions.write().await;
1554                        *admin_sessions = server_state.sessions.len() as u64;
1555                    }
1556                }
1557            });
1558
1559            // Run admin server
1560            tokio::select! {
1561                result = admin_server.run() => {
1562                    if let Err(e) = result {
1563                        tracing::error!("Admin server error: {}", e);
1564                    }
1565                }
1566                _ = shutdown_rx.recv() => {
1567                    tracing::info!("Admin server shutting down");
1568                }
1569            }
1570
1571            sync_task.abort();
1572        })
1573    }
1574
1575    /// Handle a client connection
1576    async fn handle_client(
1577        stream: TcpStream,
1578        addr: SocketAddr,
1579        state: Arc<ServerState>,
1580        config: ProxyConfig,
1581        _shutdown_tx: broadcast::Sender<()>,
1582    ) -> Result<()> {
1583        tracing::debug!("New client connection from {}", addr);
1584
1585        // Create session
1586        let session = Arc::new(ClientSession {
1587            id: Uuid::new_v4(),
1588            client_addr: addr,
1589            current_node: RwLock::new(None),
1590            in_transaction: std::sync::atomic::AtomicBool::new(false),
1591            copy_in_progress: std::sync::atomic::AtomicBool::new(false),
1592            tx_state: RwLock::new(TransactionState::default()),
1593            variables: RwLock::new(HashMap::new()),
1594            created_at: chrono::Utc::now(),
1595            tr_mode: config.tr_mode,
1596            #[cfg(feature = "lag-routing")]
1597            last_write_at: RwLock::new(None),
1598            #[cfg(feature = "pool-modes")]
1599            pool_client_id: ClientId::new(),
1600            #[cfg(feature = "wasm-plugins")]
1601            plugin_identity: RwLock::new(None),
1602            #[cfg(feature = "edge-proxy")]
1603            edge_ineligible: std::sync::atomic::AtomicBool::new(false),
1604            #[cfg(feature = "edge-proxy")]
1605            pending_edge_copy_tables: std::sync::Mutex::new(None),
1606        });
1607
1608        // Register the session, then attach an RAII guard that deregisters it on
1609        // ANY exit from here on — a normal return OR a panic unwind. Before this,
1610        // a panic anywhere in negotiation/startup/the query loop leaked the
1611        // ClientSession entry forever, inflating the active-session gauge and
1612        // stalling every graceful drain to its full timeout. The guard also
1613        // owns the rest of the per-connection teardown (metric + L1 cache) so it
1614        // too runs unconditionally.
1615        state.sessions.insert(session.id, session.clone());
1616        let _session_guard = SessionGuard {
1617            state: state.clone(),
1618            session_id: session.id,
1619        };
1620
1621        // Negotiate client TLS (if the client sent SSLRequest). Produces a
1622        // ClientStream that is plaintext or TLS-wrapped; the rest of the
1623        // session is written against that single stream type. `pre` carries
1624        // a first startup/cancel message already read while peeking.
1625        //
1626        // Bound the pre-auth negotiation (first-message read + TLS handshake) in
1627        // time: a client that connects and then stalls must not pin this task
1628        // and its session-map slot indefinitely (slow-loris). The query loop
1629        // that follows is intentionally NOT under this deadline — only the
1630        // handshake is.
1631        let negotiated = match tokio::time::timeout(
1632            Self::STARTUP_TIMEOUT,
1633            Self::negotiate_client_tls(stream, &state),
1634        )
1635        .await
1636        {
1637            Ok(r) => r,
1638            Err(_) => {
1639                tracing::debug!(client = %addr, "pre-auth negotiation timed out; closing");
1640                Err(ProxyError::Connection(
1641                    "startup negotiation timeout".to_string(),
1642                ))
1643            }
1644        };
1645        let result = match negotiated {
1646            Ok((mut client_stream, pre)) => {
1647                Self::client_loop(&mut client_stream, pre, &session, &state, &config).await
1648            }
1649            Err(e) => Err(e),
1650        };
1651
1652        // Session deregistration, the connections-closed metric, and the L1
1653        // cache reclaim are all handled by `_session_guard`'s Drop (which runs
1654        // here on the normal path and on an unwind), so nothing is required here.
1655        result
1656    }
1657
1658    /// Main client processing loop with full PostgreSQL protocol handling
1659    async fn client_loop(
1660        stream: &mut ClientStream,
1661        pre: Option<StartupMessage>,
1662        session: &Arc<ClientSession>,
1663        state: &Arc<ServerState>,
1664        config: &ProxyConfig,
1665    ) -> Result<()> {
1666        let codec = ProtocolCodec::new();
1667        let mut buffer = BytesMut::with_capacity(8192);
1668
1669        // Handle startup phase. The session keeps a per-node cache of
1670        // authenticated backend connections (`conns`) instead of a single
1671        // stream: when read/write routing moves a session between primary
1672        // and standby it now reuses the already-authenticated connection to
1673        // each node rather than dropping the socket and paying a fresh TCP
1674        // connect + startup + SCRAM handshake on every switch (Batch C).
1675        // Connections are authenticated with the client's own credentials
1676        // (auth is pass-through). In Transaction/Statement pooling mode they
1677        // are returned to a shared, identity-keyed idle pool at each
1678        // transaction boundary (DISCARD ALL reset on release) and reused by the
1679        // next same-identity acquisition — see `release_to_pool_if_idle` /
1680        // `ensure_conn`. The first connection of a session is still
1681        // established through the authenticated startup path; drawing the
1682        // startup connection from the pool (to reduce *concurrent* backend
1683        // connections below the client count) additionally needs
1684        // proxy-terminated backend auth and is the documented next increment.
1685        let mut conns: HashMap<String, BackendConn> = HashMap::new();
1686        // Bound the startup/authentication exchange in time (the TLS path reads
1687        // the real startup packet here, after negotiation). A client that opens
1688        // the connection but never completes startup must not hold the task and
1689        // its session slot open indefinitely.
1690        let startup_result = match tokio::time::timeout(
1691            Self::STARTUP_TIMEOUT,
1692            Self::handle_startup(stream, &mut buffer, &codec, pre, session, state, config),
1693        )
1694        .await
1695        {
1696            Ok(r) => r,
1697            Err(_) => Err(ProxyError::Connection("startup timeout".to_string())),
1698        };
1699        let mut current_node: Option<String> = match startup_result {
1700            Ok((Some(stream_conn), node_addr)) => {
1701                conns.insert(node_addr.clone(), BackendConn::new(stream_conn));
1702                Some(node_addr)
1703            }
1704            Ok((None, _)) => {
1705                // SSL rejected or cancel request, connection should close
1706                return Ok(());
1707            }
1708            Err(e) => {
1709                tracing::error!("Startup failed: {}", e);
1710                // Send error to client
1711                let err_msg =
1712                    Self::create_error_response("08006", &format!("Startup failed: {}", e));
1713                let _ = stream.write_all(&err_msg).await;
1714                return Err(e);
1715            }
1716        };
1717
1718        // Main query loop.
1719        //
1720        // Two wire shapes are handled. Simple-query (`Query`) messages are
1721        // self-contained: route, forward, and stream the response back
1722        // frame-by-frame until ReadyForQuery. Extended-protocol messages
1723        // (`Parse`/`Bind`/`Describe`/`Execute`/`Close`) carry no response of
1724        // their own until the client sends `Sync` (or `Flush`), so they are
1725        // accumulated into `pending` and forwarded as one batch at that
1726        // boundary — this is what stops the per-message 30s backend-read
1727        // timeout that made every prepared-statement driver unusable. The
1728        // routing decision for an extended batch is taken from the SQL in its
1729        // first `Parse`; a batch with no `Parse` (a re-`Bind`/`Execute` of a
1730        // named prepared statement) stays on the connection the statement was
1731        // prepared on.
1732        let mut pending = BytesMut::new();
1733        let mut pending_route_sql: Option<String> = None;
1734        // Prepared-statement tracking (Batch F.4). `stmt_registry` is the
1735        // session's canonical record of every *named* `Parse` the client has
1736        // issued (name -> full Parse message bytes) so the proxy can re-prepare
1737        // a statement on any backend connection that is missing it. `batch_*`
1738        // accumulate, for the in-flight extended batch, which named statements
1739        // it defines (Parse), references (Bind/Describe-S), and closes
1740        // (Close-S) — resolved at the Sync/Flush boundary.
1741        let mut stmt_registry: HashMap<String, bytes::Bytes> = HashMap::new();
1742        // Running sum of the bytes held in `stmt_registry`, kept in step with
1743        // it so the aggregate-size cap is O(1) per Parse (see MAX_PREPARED_BYTES).
1744        let mut stmt_registry_bytes: usize = 0;
1745        let mut batch_defines: Vec<String> = Vec::new();
1746        let mut batch_refs: Vec<String> = Vec::new();
1747        let mut batch_closes: Vec<String> = Vec::new();
1748        // Edge invalidation metadata for extended-protocol statements,
1749        // memoized at Parse time (name -> Some(tables) when the statement is
1750        // DML, None when read-only) so steady-state Bind/Execute cycles of a
1751        // prepared write cost a map lookup, not a regex pass. The unnamed
1752        // statement gets its own slot; `edge_batch_bound_unnamed` records
1753        // whether the in-flight batch Bind-referenced it. Resolved into an
1754        // invalidation at the Sync boundary.
1755        #[cfg(feature = "edge-proxy")]
1756        let mut edge_stmt_meta: HashMap<String, Option<Vec<String>>> = HashMap::new();
1757        #[cfg(feature = "edge-proxy")]
1758        let mut edge_unnamed_meta: Option<Vec<String>> = None;
1759        #[cfg(feature = "edge-proxy")]
1760        let mut edge_batch_bound_unnamed = false;
1761        // Unnamed-`Parse` promotion (Batch H). `held_unnamed` parks an unnamed
1762        // Parse that is the FIRST message of a batch (so the batch stays the
1763        // clean Parse→Bind→…→Sync shape) — it is NOT appended to `pending`; the
1764        // decision to forward or skip it is taken at the batch boundary once the
1765        // target connection is known. Holds (full Parse message, signature).
1766        let promote_unnamed = config.optimize_unnamed_parse;
1767        let mut held_unnamed: Option<(bytes::Bytes, bytes::Bytes)> = None;
1768        loop {
1769            // Read the client's next message directly into the accumulation
1770            // buffer (no intermediate zeroed scratch, no extra copy). `read_buf`
1771            // appends into `buffer`'s spare capacity and advances its length.
1772            //
1773            // While waiting, ALSO watch the session's current backend connection
1774            // so unsolicited backend traffic — LISTEN/NOTIFY notifications,
1775            // NoticeResponse, ParameterStatus, and the delayed tail of a `Flush`
1776            // response — is relayed to the client promptly instead of sitting
1777            // unread until the next query, and a backend that dies while the
1778            // session is idle is noticed at once. At the top of the loop the
1779            // backend is always quiescent (every query response is fully drained
1780            // before we return here), so any bytes it produces are out-of-band
1781            // and are relayed verbatim. A mid-COPY backend is excluded — it is
1782            // legitimately awaiting CopyData, which the client drives.
1783            buffer.reserve(16384);
1784            let watch_node: Option<String> = if session
1785                .copy_in_progress
1786                .load(std::sync::atomic::Ordering::Relaxed)
1787            {
1788                None
1789            } else {
1790                current_node.clone().filter(|node| conns.contains_key(node))
1791            };
1792            let n: usize = 'client_read: {
1793                let Some(node) = watch_node.as_deref() else {
1794                    break 'client_read stream
1795                        .read_buf(&mut buffer)
1796                        .await
1797                        .map_err(|e| ProxyError::Network(format!("Read error: {}", e)))?;
1798                };
1799                loop {
1800                    let mut backend_gone = false;
1801                    let mut client_bytes: Option<usize> = None;
1802                    {
1803                        let bc = conns.get_mut(node).expect("watch_node is in conns");
1804                        let mut abuf = [0u8; 16384];
1805                        tokio::select! {
1806                            r = stream.read_buf(&mut buffer) => {
1807                                client_bytes = Some(r.map_err(|e| {
1808                                    ProxyError::Network(format!("Read error: {}", e))
1809                                })?);
1810                            }
1811                            r = bc.stream.read(&mut abuf) => match r {
1812                                Ok(0) => backend_gone = true,
1813                                Ok(bn) => {
1814                                    stream.write_all(&abuf[..bn]).await.map_err(|e| {
1815                                        ProxyError::Network(format!("Client write error: {}", e))
1816                                    })?;
1817                                    state.metrics.bytes_sent.fetch_add(bn as u64, Ordering::Relaxed);
1818                                }
1819                                Err(e) => {
1820                                    tracing::debug!(node = %node, error = %e, "backend read error while idle; dropping cached connection");
1821                                    backend_gone = true;
1822                                }
1823                            },
1824                        }
1825                    }
1826                    if backend_gone {
1827                        // Drop the dead cached connection but keep the client
1828                        // session alive — the next query redials. (Mid-transaction
1829                        // the next forward fails and surfaces the error.)
1830                        conns.remove(node);
1831                        if current_node.as_deref() == Some(node) {
1832                            current_node = None;
1833                        }
1834                        break 'client_read stream
1835                            .read_buf(&mut buffer)
1836                            .await
1837                            .map_err(|e| ProxyError::Network(format!("Read error: {}", e)))?;
1838                    }
1839                    if let Some(cn) = client_bytes {
1840                        break 'client_read cn;
1841                    }
1842                    // Otherwise we relayed async backend bytes; keep watching.
1843                }
1844            };
1845
1846            if n == 0 {
1847                // Client disconnected
1848                break;
1849            }
1850
1851            state
1852                .metrics
1853                .bytes_received
1854                .fetch_add(n as u64, Ordering::Relaxed);
1855
1856            // Bound a single in-flight message: refuse before the accumulation
1857            // buffer for one (possibly malicious) oversized frame can exhaust
1858            // memory. A legitimate client never needs a single >64 MiB message.
1859            if buffer.len() > Self::MAX_PENDING_BYTES {
1860                let emsg =
1861                    Self::create_error_response("53400", "message exceeds per-session size limit");
1862                let _ = stream.write_all(&emsg).await;
1863                let _ = stream.write_all(&Self::create_ready_for_query(b'I')).await;
1864                tracing::warn!(
1865                    client = %session.client_addr,
1866                    bytes = buffer.len(),
1867                    "inbound message exceeds size cap; closing connection"
1868                );
1869                return Ok(());
1870            }
1871
1872            // Process all complete messages in buffer
1873            while let Some(msg) = codec.decode_message(&mut buffer)? {
1874                match msg.msg_type {
1875                    MessageType::Terminate => return Ok(()),
1876
1877                    // ---- Simple query protocol ----
1878                    MessageType::Query => {
1879                        // Anomaly detector — record every Query message before
1880                        // the plugin hook so a detection lands in the audit
1881                        // trail even if a plugin later blocks.
1882                        #[cfg(feature = "anomaly-detection")]
1883                        Self::record_anomaly_observation(&msg, state, session);
1884
1885                        // Plugin pre-query hook — may rewrite the SQL, block,
1886                        // or return a cached response.
1887                        let (msg, action) = Self::apply_pre_query_hook(msg, state, session);
1888
1889                        if let PreQueryAction::Block(reason) = &action {
1890                            tracing::info!(reason = %reason, "pre-query plugin blocked query");
1891                            Self::send_block_response(stream, reason, state).await?;
1892                            state
1893                                .metrics
1894                                .queries_processed
1895                                .fetch_add(1, Ordering::Relaxed);
1896                            continue;
1897                        }
1898
1899                        #[cfg(feature = "wasm-plugins")]
1900                        if let PreQueryAction::Cached(bytes) = &action {
1901                            match Self::synthesise_cached_response(bytes) {
1902                                Ok(reply) => {
1903                                    stream.write_all(&reply).await.map_err(|e| {
1904                                        ProxyError::Network(format!("Write error: {}", e))
1905                                    })?;
1906                                    state
1907                                        .metrics
1908                                        .bytes_sent
1909                                        .fetch_add(reply.len() as u64, Ordering::Relaxed);
1910                                    state
1911                                        .metrics
1912                                        .queries_processed
1913                                        .fetch_add(1, Ordering::Relaxed);
1914                                    continue;
1915                                }
1916                                Err(e) => {
1917                                    tracing::warn!(error = %e, "failed to synthesise cached response; falling back to backend");
1918                                }
1919                            }
1920                        }
1921
1922                        // Traffic mirror: offer the (final, post-rewrite)
1923                        // statement to the secondary backend. Non-blocking —
1924                        // never delays the client path.
1925                        if let Some(ref mirror) = state.mirror {
1926                            if let Some(sql) = crate::protocol::query_text(&msg.payload) {
1927                                mirror.offer(sql, Self::is_write_query(sql));
1928                            }
1929                        }
1930
1931                        #[cfg(feature = "wasm-plugins")]
1932                        let forward_start = std::time::Instant::now();
1933                        let fr = Self::forward_simple_query(
1934                            stream,
1935                            &msg,
1936                            &mut conns,
1937                            current_node.as_deref(),
1938                            session,
1939                            state,
1940                            config,
1941                        )
1942                        .await;
1943                        #[cfg(feature = "wasm-plugins")]
1944                        Self::fire_post_query_hook(
1945                            &msg,
1946                            session,
1947                            state,
1948                            &fr,
1949                            forward_start.elapsed(),
1950                        );
1951                        let (used_node, sent) = fr?;
1952                        if let Some(n) = used_node {
1953                            current_node = Some(n);
1954                        }
1955                        // Transaction/Statement pooling: park the connection
1956                        // back to the shared pool once the session is idle.
1957                        #[cfg(feature = "pool-modes")]
1958                        Self::release_to_pool_if_idle(
1959                            &mut conns,
1960                            current_node.as_deref(),
1961                            session,
1962                            state,
1963                            config,
1964                        )
1965                        .await;
1966                        state.metrics.bytes_sent.fetch_add(sent, Ordering::Relaxed);
1967                        state
1968                            .metrics
1969                            .queries_processed
1970                            .fetch_add(1, Ordering::Relaxed);
1971                    }
1972
1973                    // ---- Extended query protocol: accumulate until Sync/Flush ----
1974                    MessageType::Parse
1975                    | MessageType::Bind
1976                    | MessageType::Describe
1977                    | MessageType::Execute
1978                    | MessageType::Close => {
1979                        // Whether this message is appended to `pending`. An
1980                        // unnamed Parse held aside for promotion is the lone
1981                        // exception (resolved at the batch boundary).
1982                        let mut add_to_pending = true;
1983                        match msg.msg_type {
1984                            MessageType::Parse => {
1985                                // Register named statements so they can be
1986                                // re-prepared on a different backend later, and
1987                                // borrow the query (2nd cstring) for routing.
1988                                let name = Self::parse_stmt_name(&msg.payload);
1989                                let unnamed = name.is_empty();
1990                                if !unnamed {
1991                                    let name = name.to_string();
1992                                    let existed = stmt_registry.contains_key(&name);
1993                                    // Cap distinct prepared statements per session so a
1994                                    // client issuing unbounded named `Parse`s can't grow
1995                                    // `stmt_registry` without limit.
1996                                    if !existed
1997                                        && stmt_registry.len() >= Self::MAX_PREPARED_STATEMENTS
1998                                    {
1999                                        let emsg = Self::create_error_response(
2000                                            "54000",
2001                                            "too many prepared statements for this session",
2002                                        );
2003                                        let _ = stream.write_all(&emsg).await;
2004                                        let _ = stream
2005                                            .write_all(&Self::create_ready_for_query(b'I'))
2006                                            .await;
2007                                        tracing::warn!(
2008                                            client = %session.client_addr,
2009                                            limit = Self::MAX_PREPARED_STATEMENTS,
2010                                            "prepared-statement cap exceeded; closing connection"
2011                                        );
2012                                        return Ok(());
2013                                    }
2014                                    let encoded = msg.encode().freeze();
2015                                    // Bound the AGGREGATE bytes retained, not just the
2016                                    // count: a (possibly re-Parsed) statement that would
2017                                    // push the session over the byte cap is refused.
2018                                    let old_len =
2019                                        stmt_registry.get(&name).map(|b| b.len()).unwrap_or(0);
2020                                    let projected =
2021                                        stmt_registry_bytes.saturating_sub(old_len) + encoded.len();
2022                                    if projected > Self::MAX_PREPARED_BYTES {
2023                                        let emsg = Self::create_error_response(
2024                                            "54000",
2025                                            "prepared-statement memory limit exceeded for this session",
2026                                        );
2027                                        let _ = stream.write_all(&emsg).await;
2028                                        let _ = stream
2029                                            .write_all(&Self::create_ready_for_query(b'I'))
2030                                            .await;
2031                                        tracing::warn!(
2032                                            client = %session.client_addr,
2033                                            limit = Self::MAX_PREPARED_BYTES,
2034                                            "prepared-statement byte cap exceeded; closing connection"
2035                                        );
2036                                        return Ok(());
2037                                    }
2038                                    stmt_registry.insert(name.clone(), encoded);
2039                                    stmt_registry_bytes = projected;
2040                                    batch_defines.push(name);
2041                                }
2042                                if pending_route_sql.is_none() {
2043                                    if let Some(end) = msg.payload.iter().position(|&b| b == 0) {
2044                                        if let Some(q) =
2045                                            crate::protocol::query_text(&msg.payload[end + 1..])
2046                                        {
2047                                            if !q.is_empty() {
2048                                                pending_route_sql = Some(q.to_string());
2049                                                #[cfg(feature = "anomaly-detection")]
2050                                                Self::record_anomaly_sql(q, state, session);
2051                                            }
2052                                        }
2053                                    }
2054                                }
2055                                // Edge invalidation metadata: classify every
2056                                // Parse'd statement ONCE (is it DML? which
2057                                // tables?) so Sync-time invalidation of
2058                                // re-executed prepared writes is a map hit.
2059                                // Extended-protocol statements can dirty
2060                                // session state too (JDBC-style SET) — the
2061                                // sticky edge-ineligibility flag must catch
2062                                // them, not just simple-protocol text.
2063                                #[cfg(feature = "edge-proxy")]
2064                                if config.edge.enabled {
2065                                    if let Some(end) = msg.payload.iter().position(|&b| b == 0) {
2066                                        if let Some(q) =
2067                                            crate::protocol::query_text(&msg.payload[end + 1..])
2068                                        {
2069                                            if !session
2070                                                .edge_ineligible
2071                                                .load(std::sync::atomic::Ordering::Relaxed)
2072                                                && Self::stmt_leaves_session_state(q)
2073                                            {
2074                                                session.edge_ineligible.store(
2075                                                    true,
2076                                                    std::sync::atomic::Ordering::Relaxed,
2077                                                );
2078                                            }
2079                                            let meta = if Self::is_edge_dml_sql(q) {
2080                                                Some(crate::edge::fingerprint::tables_only(q))
2081                                            } else if Self::is_edge_procedural_sql(q)
2082                                                || Self::is_edge_txn_end_sql(q)
2083                                            {
2084                                                // Opaque write (EXECUTE/CALL/DO) or a
2085                                                // COMMIT/END that makes in-transaction
2086                                                // writes visible — memoize as the
2087                                                // empty-set wildcard so the batch's
2088                                                // Sync hook full-flushes.
2089                                                Some(Vec::new())
2090                                            } else {
2091                                                None
2092                                            };
2093                                            if unnamed {
2094                                                edge_unnamed_meta = meta;
2095                                            } else {
2096                                                edge_stmt_meta.insert(
2097                                                    Self::parse_stmt_name(&msg.payload).to_string(),
2098                                                    meta,
2099                                                );
2100                                            }
2101                                        }
2102                                    }
2103                                }
2104                                // Promotion: park an unnamed Parse that opens a
2105                                // fresh batch. Its signature is the payload after
2106                                // the empty statement-name NUL (query + param
2107                                // types). Anything that breaks the clean shape
2108                                // (a second Parse, a non-empty `pending`) un-parks
2109                                // it back into `pending` to preserve wire order.
2110                                if promote_unnamed
2111                                    && unnamed
2112                                    && pending.is_empty()
2113                                    && held_unnamed.is_none()
2114                                {
2115                                    let sig = bytes::Bytes::copy_from_slice(&msg.payload[1..]);
2116                                    held_unnamed = Some((msg.encode().freeze(), sig));
2117                                    add_to_pending = false;
2118                                } else if let Some((held_msg, _)) = held_unnamed.take() {
2119                                    let mut combined =
2120                                        BytesMut::with_capacity(held_msg.len() + pending.len());
2121                                    combined.extend_from_slice(&held_msg);
2122                                    combined.extend_from_slice(&pending);
2123                                    pending = combined;
2124                                }
2125                            }
2126                            MessageType::Bind => {
2127                                let stmt_ref = Self::bind_stmt_ref(&msg.payload);
2128                                // Unnamed statement: not tracked in
2129                                // batch_refs, but the edge invalidation must
2130                                // still see its execution.
2131                                #[cfg(feature = "edge-proxy")]
2132                                if stmt_ref.is_none() {
2133                                    edge_batch_bound_unnamed = true;
2134                                }
2135                                if let Some(name) = stmt_ref {
2136                                    batch_refs.push(name.to_string());
2137                                }
2138                            }
2139                            MessageType::Describe => {
2140                                if let Some(name) = Self::stmt_kind_name(&msg.payload) {
2141                                    batch_refs.push(name.to_string());
2142                                }
2143                            }
2144                            MessageType::Close => {
2145                                if let Some(name) = Self::stmt_kind_name(&msg.payload) {
2146                                    batch_closes.push(name.to_string());
2147                                }
2148                            }
2149                            _ => {}
2150                        }
2151                        if add_to_pending {
2152                            pending.extend_from_slice(&msg.encode());
2153                        }
2154                    }
2155
2156                    // ---- Extended batch boundary ----
2157                    MessageType::Sync | MessageType::Flush => {
2158                        let wait_ready = msg.msg_type == MessageType::Sync;
2159                        pending.extend_from_slice(&msg.encode());
2160                        let batch = pending.split().freeze();
2161                        // Re-prepare any named statement this batch references
2162                        // but does not itself define, in case the target
2163                        // connection (after a switch/redial) is missing it.
2164                        let reprepare: Vec<String> = batch_refs
2165                            .iter()
2166                            .filter(|r| !batch_defines.contains(r))
2167                            .cloned()
2168                            .collect();
2169                        let (used_node, sent) = Self::forward_extended_batch(
2170                            stream,
2171                            &batch,
2172                            pending_route_sql.as_deref(),
2173                            wait_ready,
2174                            &mut conns,
2175                            current_node.as_deref(),
2176                            &stmt_registry,
2177                            &reprepare,
2178                            &batch_defines,
2179                            held_unnamed.take(),
2180                            session,
2181                            state,
2182                            config,
2183                        )
2184                        .await?;
2185                        if let Some(n) = used_node {
2186                            current_node = Some(n);
2187                        }
2188                        // A `Sync` is the extended-protocol transaction/statement
2189                        // boundary (it yields ReadyForQuery); a `Flush` is not, so
2190                        // only a Sync triggers a pool release.
2191                        #[cfg(feature = "pool-modes")]
2192                        if wait_ready {
2193                            Self::release_to_pool_if_idle(
2194                                &mut conns,
2195                                current_node.as_deref(),
2196                                session,
2197                                state,
2198                                config,
2199                            )
2200                            .await;
2201                        }
2202                        state.metrics.bytes_sent.fetch_add(sent, Ordering::Relaxed);
2203                        // Extended-protocol writes drove zero edge invalidation
2204                        // before this hook — prepared-statement drivers
2205                        // (JDBC/Npgsql/asyncpg) would leave every edge stale
2206                        // until TTL. Union the tables of the batch's referenced
2207                        // DML statements (memoized at Parse) and invalidate/
2208                        // broadcast exactly like the simple path. Errors inside
2209                        // the batch only over-invalidate — safe. A COPY ... FROM
2210                        // batch is deferred to its CopyDone drain (rows visible
2211                        // then). Runs BEFORE the batch_closes drain below, so an
2212                        // execute-then-Close batch still sees its statement's
2213                        // metadata; only Sync-terminated batches fire (a pure
2214                        // Flush pipeline accumulates refs until its Sync).
2215                        #[cfg(feature = "edge-proxy")]
2216                        if wait_ready && config.edge.enabled {
2217                            if let Some(tables) = Self::edge_extended_batch_tables(
2218                                &batch_refs,
2219                                edge_batch_bound_unnamed,
2220                                &edge_stmt_meta,
2221                                &edge_unnamed_meta,
2222                            ) {
2223                                if session
2224                                    .copy_in_progress
2225                                    .load(std::sync::atomic::Ordering::Relaxed)
2226                                {
2227                                    *session
2228                                        .pending_edge_copy_tables
2229                                        .lock()
2230                                        .unwrap_or_else(|e| e.into_inner()) = Some(tables);
2231                                } else {
2232                                    Self::edge_invalidate_write(state, config, tables).await;
2233                                }
2234                            }
2235                            edge_batch_bound_unnamed = false;
2236                        }
2237                        // Closed statements are deallocated everywhere — forget
2238                        // their canonical Parse so they are never re-prepared.
2239                        #[cfg(not(feature = "edge-proxy"))]
2240                        for name in batch_closes.drain(..) {
2241                            if let Some(removed) = stmt_registry.remove(&name) {
2242                                stmt_registry_bytes =
2243                                    stmt_registry_bytes.saturating_sub(removed.len());
2244                            }
2245                        }
2246                        // Edge builds also prune per-statement invalidation metadata
2247                        // — but that metadata is consumed by the Sync-time hook ABOVE
2248                        // (which runs first) and must survive a Close seen at an
2249                        // earlier Flush of the same batch, and a name Closed then
2250                        // re-Parsed in this batch must keep its FRESH meta. So the
2251                        // close queue is drained only at the Sync (stmt_registry is
2252                        // still pruned every boundary — idempotent), and edge meta is
2253                        // removed there only for names not redefined this batch.
2254                        // Retaining meta for a genuinely-closed name is safe: a later
2255                        // re-Parse overwrites it, and a stale entry only ever
2256                        // over-invalidates.
2257                        #[cfg(feature = "edge-proxy")]
2258                        {
2259                            for name in &batch_closes {
2260                                if let Some(removed) = stmt_registry.remove(name) {
2261                                    stmt_registry_bytes =
2262                                        stmt_registry_bytes.saturating_sub(removed.len());
2263                                }
2264                            }
2265                            if wait_ready {
2266                                for name in Self::edge_meta_prunable(&batch_closes, &batch_defines)
2267                                {
2268                                    edge_stmt_meta.remove(name);
2269                                }
2270                                batch_closes.clear();
2271                            }
2272                        }
2273                        if wait_ready {
2274                            // Sync ends the extended cycle; reset routing so the
2275                            // next Parse can re-route. Flush leaves it intact so
2276                            // the rest of the in-flight sequence stays put.
2277                            pending_route_sql = None;
2278                            batch_defines.clear();
2279                            batch_refs.clear();
2280                            state
2281                                .metrics
2282                                .queries_processed
2283                                .fetch_add(1, Ordering::Relaxed);
2284                        }
2285                    }
2286
2287                    // ---- COPY sub-protocol (client -> backend) ----
2288                    MessageType::CopyData | MessageType::CopyDone | MessageType::CopyFail => {
2289                        let is_copy_end =
2290                            matches!(msg.msg_type, MessageType::CopyDone | MessageType::CopyFail);
2291                        let conn = current_node.as_ref().and_then(|n| conns.get_mut(n));
2292                        match conn {
2293                            Some(b) => {
2294                                b.stream.write_all(&msg.encode()).await.map_err(|e| {
2295                                    ProxyError::Network(format!("Backend copy write error: {}", e))
2296                                })?;
2297                                if is_copy_end {
2298                                    let node = current_node.clone().unwrap();
2299                                    let r = Self::stream_until_ready(
2300                                        stream,
2301                                        &mut b.stream,
2302                                        session,
2303                                        state,
2304                                    )
2305                                    .await;
2306                                    // Copy has drained back to ReadyForQuery — the
2307                                    // session is no longer mid-COPY, so pool release
2308                                    // is allowed again.
2309                                    session
2310                                        .copy_in_progress
2311                                        .store(false, std::sync::atomic::Ordering::Relaxed);
2312                                    match r {
2313                                        Ok(sent) => {
2314                                            state
2315                                                .metrics
2316                                                .bytes_sent
2317                                                .fetch_add(sent, Ordering::Relaxed);
2318                                            // COPY ... FROM rows became visible at
2319                                            // this drain: fire the deferred edge
2320                                            // invalidation now. CopyFail loaded
2321                                            // nothing — just clear the stash.
2322                                            #[cfg(feature = "edge-proxy")]
2323                                            if config.edge.enabled {
2324                                                let stashed = session
2325                                                    .pending_edge_copy_tables
2326                                                    .lock()
2327                                                    .unwrap_or_else(|e| e.into_inner())
2328                                                    .take();
2329                                                if let Some(tables) = stashed {
2330                                                    if matches!(msg.msg_type, MessageType::CopyDone)
2331                                                    {
2332                                                        Self::edge_invalidate_write(
2333                                                            state, config, tables,
2334                                                        )
2335                                                        .await;
2336                                                    }
2337                                                }
2338                                            }
2339                                        }
2340                                        Err(e) => {
2341                                            conns.remove(&node);
2342                                            return Err(e);
2343                                        }
2344                                    }
2345                                }
2346                            }
2347                            None => {
2348                                // The client is streaming COPY frames but the
2349                                // backend connection is gone (dropped/redialed).
2350                                // Silently discarding them hangs the client
2351                                // forever; instead tell it the copy failed and
2352                                // return it to a clean idle state.
2353                                session
2354                                    .copy_in_progress
2355                                    .store(false, std::sync::atomic::Ordering::Relaxed);
2356                                // The COPY aborted — nothing loaded — so discard any
2357                                // deferred invalidation stash, else it would later
2358                                // fire against an unrelated COPY's drain.
2359                                #[cfg(feature = "edge-proxy")]
2360                                {
2361                                    *session
2362                                        .pending_edge_copy_tables
2363                                        .lock()
2364                                        .unwrap_or_else(|e| e.into_inner()) = None;
2365                                }
2366                                if is_copy_end {
2367                                    let emsg = Self::create_error_response(
2368                                        "57000",
2369                                        "COPY aborted: backend connection lost",
2370                                    );
2371                                    let _ = stream.write_all(&emsg).await;
2372                                    let _ =
2373                                        stream.write_all(&Self::create_ready_for_query(b'I')).await;
2374                                }
2375                            }
2376                        }
2377                    }
2378
2379                    // ---- Anything else: forward to current backend best-effort ----
2380                    _ => {
2381                        if let Some(ref node) = current_node {
2382                            if let Some(b) = conns.get_mut(node) {
2383                                let _ = b.stream.write_all(&msg.encode()).await;
2384                            }
2385                        }
2386                    }
2387                }
2388            }
2389
2390            // Bound un-flushed extended-protocol accumulation: a client must
2391            // reach a Sync/Flush boundary before this many bytes pile up in
2392            // `pending` (otherwise a never-syncing client grows it unbounded).
2393            if pending.len() > Self::MAX_PENDING_BYTES {
2394                let emsg = Self::create_error_response(
2395                    "53400",
2396                    "un-flushed extended-protocol buffer exceeds per-session limit",
2397                );
2398                let _ = stream.write_all(&emsg).await;
2399                let _ = stream.write_all(&Self::create_ready_for_query(b'I')).await;
2400                tracing::warn!(
2401                    client = %session.client_addr,
2402                    pending = pending.len(),
2403                    "pending extended-protocol buffer cap exceeded; closing connection"
2404                );
2405                return Ok(());
2406            }
2407        }
2408
2409        // On disconnect, park this session's still-idle connections so a later
2410        // same-identity client can reuse them (cross-client pooling). Anything
2411        // mid-transaction is left to drop (closed → backend rolls back).
2412        #[cfg(feature = "pool-modes")]
2413        if state.backend_pool.is_some() {
2414            let nodes: Vec<String> = conns.keys().cloned().collect();
2415            for node in nodes {
2416                Self::release_to_pool_if_idle(
2417                    &mut conns,
2418                    Some(node.as_str()),
2419                    session,
2420                    state,
2421                    config,
2422                )
2423                .await;
2424            }
2425        }
2426
2427        Ok(())
2428    }
2429
2430    /// Peek the first startup-phase message and negotiate client TLS.
2431    ///
2432    /// On `SSLRequest` the proxy answers `S` and runs a rustls server
2433    /// handshake when a TLS acceptor is configured, otherwise `N`
2434    /// (plaintext). A `Startup`/`CancelRequest` arriving first (no
2435    /// SSLRequest) is returned in `pre` so the caller doesn't re-read it.
2436    async fn negotiate_client_tls(
2437        mut tcp: TcpStream,
2438        state: &Arc<ServerState>,
2439    ) -> Result<(ClientStream, Option<StartupMessage>)> {
2440        let codec = ProtocolCodec::new();
2441        let mut buffer = BytesMut::with_capacity(1024);
2442        let mut read_buf = vec![0u8; 1024];
2443
2444        let first = loop {
2445            if let Some(msg) = codec.decode_startup(&mut buffer)? {
2446                break msg;
2447            }
2448            let n = tcp
2449                .read(&mut read_buf)
2450                .await
2451                .map_err(|e| ProxyError::Network(format!("Startup read error: {}", e)))?;
2452            if n == 0 {
2453                return Err(ProxyError::Connection(
2454                    "client closed before startup".to_string(),
2455                ));
2456            }
2457            buffer.extend_from_slice(&read_buf[..n]);
2458        };
2459
2460        match first {
2461            StartupMessage::SSLRequest => match state.tls_acceptor.as_ref() {
2462                Some(acceptor) => {
2463                    tcp.write_all(b"S")
2464                        .await
2465                        .map_err(|e| ProxyError::Network(format!("SSL accept write: {}", e)))?;
2466                    let tls = acceptor
2467                        .accept(tcp)
2468                        .await
2469                        .map_err(|e| ProxyError::Network(format!("TLS handshake failed: {}", e)))?;
2470                    if tls.get_ref().1.peer_certificates().is_some() {
2471                        tracing::debug!("client presented a certificate (mTLS)");
2472                    }
2473                    Ok((ClientStream::Tls(Box::new(tls)), None))
2474                }
2475                None => {
2476                    tcp.write_all(b"N")
2477                        .await
2478                        .map_err(|e| ProxyError::Network(format!("SSL reject write: {}", e)))?;
2479                    Ok((ClientStream::Plain(tcp), None))
2480                }
2481            },
2482            other => Ok((ClientStream::Plain(tcp), Some(other))),
2483        }
2484    }
2485
2486    /// Handle PostgreSQL startup phase (authentication). TLS/SSLRequest is
2487    /// already handled upstream in `negotiate_client_tls`; `pre` carries the
2488    /// first startup/cancel message when it was read during negotiation.
2489    async fn handle_startup(
2490        client_stream: &mut ClientStream,
2491        buffer: &mut BytesMut,
2492        codec: &ProtocolCodec,
2493        pre: Option<StartupMessage>,
2494        session: &Arc<ClientSession>,
2495        state: &Arc<ServerState>,
2496        config: &ProxyConfig,
2497    ) -> Result<(Option<TcpStream>, String)> {
2498        // Use the message already read during TLS negotiation, or read one
2499        // now (the TLS case, where the real startup follows the handshake).
2500        let startup_msg = match pre {
2501            Some(msg) => Some(msg),
2502            None => {
2503                let mut read_buf = vec![0u8; 1024];
2504                loop {
2505                    if let Some(msg) = codec.decode_startup(buffer)? {
2506                        break Some(msg);
2507                    }
2508                    let n = client_stream
2509                        .read(&mut read_buf)
2510                        .await
2511                        .map_err(|e| ProxyError::Network(format!("Startup read error: {}", e)))?;
2512                    if n == 0 {
2513                        return Ok((None, String::new()));
2514                    }
2515                    buffer.extend_from_slice(&read_buf[..n]);
2516                }
2517            }
2518        };
2519
2520        match startup_msg {
2521            Some(StartupMessage::SSLRequest) => {
2522                // SSL is negotiated upstream; a second SSLRequest here is a
2523                // protocol error — reject defensively.
2524                client_stream
2525                    .write_all(b"N")
2526                    .await
2527                    .map_err(|e| ProxyError::Network(format!("SSL reject error: {}", e)))?;
2528                Err(ProxyError::Protocol(
2529                    "unexpected SSLRequest after startup".to_string(),
2530                ))
2531            }
2532            Some(StartupMessage::CancelRequest { pid, key }) => {
2533                // Forward the cancel to the backend that owns this key, then
2534                // close (the client opened this connection only to cancel).
2535                Self::forward_cancel_request(state, pid, key).await;
2536                Ok((None, String::new()))
2537            }
2538            Some(StartupMessage::Startup { params, .. }) => {
2539                Self::connect_and_authenticate(client_stream, &params, session, state, config).await
2540            }
2541            None => Err(ProxyError::Protocol(
2542                "Incomplete startup message".to_string(),
2543            )),
2544        }
2545    }
2546
2547    /// Evaluate pg_hba-style admission rules in order. The first rule whose
2548    /// user, database, and address all match decides; if none match, admit.
2549    fn hba_admits(rules: &[HbaRule], ip: std::net::IpAddr, user: &str, database: &str) -> bool {
2550        for r in rules {
2551            let user_ok = r.user == "all" || r.user == user;
2552            let db_ok = r.database == "all" || r.database == database;
2553            if user_ok && db_ok && Self::hba_addr_matches(&r.address, ip) {
2554                return r.action == HbaAction::Allow;
2555            }
2556        }
2557        true
2558    }
2559
2560    /// Match a client address against an hba `address` spec: "all", a bare
2561    /// IP, or a CIDR (`10.0.0.0/8`, `::1/128`).
2562    fn hba_addr_matches(spec: &str, ip: std::net::IpAddr) -> bool {
2563        use std::net::IpAddr;
2564        if spec == "all" {
2565            return true;
2566        }
2567        if let Some((net, bits)) = spec.split_once('/') {
2568            let bits: u32 = match bits.parse() {
2569                Ok(b) => b,
2570                Err(_) => return false,
2571            };
2572            match (net.parse::<IpAddr>(), ip) {
2573                (Ok(IpAddr::V4(n)), IpAddr::V4(i)) if bits <= 32 => {
2574                    let mask = if bits == 0 {
2575                        0
2576                    } else {
2577                        u32::MAX << (32 - bits)
2578                    };
2579                    (u32::from(n) & mask) == (u32::from(i) & mask)
2580                }
2581                (Ok(IpAddr::V6(n)), IpAddr::V6(i)) if bits <= 128 => {
2582                    let mask = if bits == 0 {
2583                        0
2584                    } else {
2585                        u128::MAX << (128 - bits)
2586                    };
2587                    (u128::from(n) & mask) == (u128::from(i) & mask)
2588                }
2589                _ => false,
2590            }
2591        } else {
2592            spec.parse::<IpAddr>().map(|s| s == ip).unwrap_or(false)
2593        }
2594    }
2595
2596    /// Run a proxy-terminated SCRAM-SHA-256 server exchange against the
2597    /// client, validating its password with the configured `auth_file`. On
2598    /// success the client is authenticated by the proxy (no AuthenticationOk
2599    /// is sent here — the backend's is forwarded later). On any failure
2600    /// returns Err; the caller emits an ErrorResponse and closes.
2601    async fn proxy_scram_auth(
2602        client: &mut ClientStream,
2603        user: &str,
2604        state: &Arc<ServerState>,
2605    ) -> std::result::Result<(), String> {
2606        use crate::auth_scram::ScramServer;
2607        let auth_file = state.auth_file.as_ref().ok_or("scram not configured")?;
2608
2609        // 1. AuthenticationSASL: advertise SCRAM-SHA-256.
2610        let mut sasl = BytesMut::new();
2611        sasl.put_i32(10); // SASL
2612        sasl.extend_from_slice(b"SCRAM-SHA-256\0");
2613        sasl.put_u8(0); // end of mechanism list
2614        Self::write_auth_frame(client, &sasl).await?;
2615
2616        // 2. Read SASLInitialResponse ('p'): mechanism cstring + i32 len + data.
2617        let init = Self::read_password_message(client).await?;
2618        let mech_end = init
2619            .iter()
2620            .position(|&b| b == 0)
2621            .ok_or("malformed SASLInitialResponse (no mechanism)")?;
2622        if init.len() < mech_end + 5 {
2623            return Err("short SASLInitialResponse".into());
2624        }
2625        let client_first =
2626            std::str::from_utf8(&init[mech_end + 5..]).map_err(|_| "client-first not UTF-8")?;
2627
2628        // 3. Look up the verifier (unknown user -> generic failure).
2629        let verifier = auth_file.get(user).ok_or("no such user")?.clone();
2630
2631        // 4. server-first.
2632        let server_nonce = Self::random_nonce();
2633        let (server, server_first) = ScramServer::start(verifier, client_first, &server_nonce)?;
2634
2635        // 5. AuthenticationSASLContinue.
2636        let mut cont = BytesMut::new();
2637        cont.put_i32(11);
2638        cont.extend_from_slice(server_first.as_bytes());
2639        Self::write_auth_frame(client, &cont).await?;
2640
2641        // 6. Read SASLResponse ('p'): payload = client-final.
2642        let client_final_raw = Self::read_password_message(client).await?;
2643        let client_final =
2644            std::str::from_utf8(&client_final_raw).map_err(|_| "client-final not UTF-8")?;
2645
2646        // 7. Verify -> server-final.
2647        let server_final = server.finish(client_final)?;
2648
2649        // 8. AuthenticationSASLFinal (no AuthenticationOk — backend's follows).
2650        let mut fin = BytesMut::new();
2651        fin.put_i32(12);
2652        fin.extend_from_slice(server_final.as_bytes());
2653        Self::write_auth_frame(client, &fin).await?;
2654        Ok(())
2655    }
2656
2657    /// Write an AuthenticationRequest ('R') frame with the given payload.
2658    async fn write_auth_frame(
2659        client: &mut ClientStream,
2660        payload: &[u8],
2661    ) -> std::result::Result<(), String> {
2662        let mut frame = BytesMut::with_capacity(payload.len() + 5);
2663        frame.put_u8(b'R');
2664        frame.put_u32((payload.len() + 4) as u32);
2665        frame.extend_from_slice(payload);
2666        client
2667            .write_all(&frame)
2668            .await
2669            .map_err(|e| format!("client write: {}", e))
2670    }
2671
2672    /// Read one Password/SASL ('p') message from the client, returning its
2673    /// payload. Errors on EOF or any non-'p' frame.
2674    async fn read_password_message(
2675        client: &mut ClientStream,
2676    ) -> std::result::Result<BytesMut, String> {
2677        let codec = ProtocolCodec::new();
2678        let mut buffer = BytesMut::with_capacity(1024);
2679        let mut read_buf = vec![0u8; 1024];
2680        loop {
2681            if let Some(msg) = codec
2682                .decode_message(&mut buffer)
2683                .map_err(|e| format!("decode: {}", e))?
2684            {
2685                if msg.msg_type == MessageType::Password {
2686                    return Ok(msg.payload);
2687                }
2688                return Err(format!("expected SASL response, got {:?}", msg.msg_type));
2689            }
2690            let n = client
2691                .read(&mut read_buf)
2692                .await
2693                .map_err(|e| format!("client read: {}", e))?;
2694            if n == 0 {
2695                return Err("client closed during SASL".into());
2696            }
2697            buffer.extend_from_slice(&read_buf[..n]);
2698        }
2699    }
2700
2701    /// A fresh random SCRAM server nonce (printable, no comma).
2702    fn random_nonce() -> String {
2703        use rand::Rng;
2704        const CHARS: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789";
2705        let mut rng = rand::thread_rng();
2706        (0..24)
2707            .map(|_| CHARS[rng.gen_range(0..CHARS.len())] as char)
2708            .collect()
2709    }
2710
2711    /// Connect to backend and handle authentication
2712    async fn connect_and_authenticate(
2713        client_stream: &mut ClientStream,
2714        params: &HashMap<String, String>,
2715        session: &Arc<ClientSession>,
2716        state: &Arc<ServerState>,
2717        config: &ProxyConfig,
2718    ) -> Result<(Option<TcpStream>, String)> {
2719        // pg_hba-style admission: reject disallowed (user, database, client
2720        // address) combinations before opening any backend connection.
2721        let user = params.get("user").map(String::as_str).unwrap_or("");
2722        let database = params.get("database").map(String::as_str).unwrap_or(user);
2723        if !Self::hba_admits(&config.hba, session.client_addr.ip(), user, database) {
2724            tracing::info!(%user, %database, client = %session.client_addr, "connection rejected by hba rule");
2725            let err = Self::create_error_response(
2726                "28000",
2727                "connection rejected by proxy admission rules",
2728            );
2729            let _ = client_stream.write_all(&err).await;
2730            return Ok((None, String::new()));
2731        }
2732
2733        // Proxy-terminated SCRAM-SHA-256: when an auth_file is configured the
2734        // proxy authenticates the client itself (becoming the auth boundary)
2735        // instead of relaying credentials to the backend. On success it falls
2736        // through to the normal backend connect, whose AuthenticationOk +
2737        // session messages are forwarded to the already-authenticated client.
2738        if state.auth_file.is_some() {
2739            if let Err(e) = Self::proxy_scram_auth(client_stream, user, state).await {
2740                tracing::info!(%user, error = %e, "proxy SCRAM auth failed");
2741                let err =
2742                    Self::create_error_response("28P01", &format!("authentication failed: {}", e));
2743                let _ = client_stream.write_all(&err).await;
2744                return Ok((None, String::new()));
2745            }
2746            tracing::debug!(%user, "client authenticated by proxy SCRAM");
2747        }
2748
2749        // Plugin Authenticate hook — may deny the connection outright or
2750        // attach a richer identity (roles, tenant_id, claims) onto the
2751        // session for downstream plugins to consume. Happens before any
2752        // backend connection is opened so denials cost nothing on the
2753        // backend side.
2754        Self::apply_authenticate_hook(params, session, state).await?;
2755
2756        // Migration cutover: when active, redirect this connection to the
2757        // promoted target, substituting the target's credentials/database for
2758        // the client's so the cutover is transparent to the application.
2759        let cutover = state.cutover.load_full();
2760        let (node_addr, effective_params) = if let Some(t) = cutover.as_ref() {
2761            let mut p = params.clone();
2762            p.insert("user".to_string(), t.user.clone());
2763            if let Some(ref db) = t.database {
2764                p.insert("database".to_string(), db.clone());
2765            } else {
2766                p.remove("database");
2767            }
2768            tracing::debug!(target = %t.addr, "routing connection to cutover target");
2769            (t.addr.clone(), p)
2770        } else {
2771            (
2772                Self::select_node(session, state, config).await?,
2773                params.clone(),
2774            )
2775        };
2776
2777        // Connect to backend. A failure here (the node is down at the moment a
2778        // new client connects) demotes the node in-band too — not just failures
2779        // on the forward path — so a dead backend is detected on the very next
2780        // connection instead of waiting for the periodic health checker.
2781        let mut backend = match tokio::time::timeout(
2782            config.pool.acquire_timeout(),
2783            TcpStream::connect(&node_addr),
2784        )
2785        .await
2786        {
2787            Ok(Ok(s)) => s,
2788            Ok(Err(e)) => {
2789                let msg = format!("Failed to connect to {}: {}", node_addr, e);
2790                Self::note_backend_failure(state, &node_addr, &msg);
2791                return Err(ProxyError::Connection(msg));
2792            }
2793            Err(_) => {
2794                let msg = format!("Connection timeout to {}", node_addr);
2795                Self::note_backend_failure(state, &node_addr, &msg);
2796                return Err(ProxyError::Connection(msg));
2797            }
2798        };
2799        let _ = backend.set_nodelay(true);
2800
2801        // Build and send startup message to backend
2802        let params = &effective_params;
2803        let startup_bytes = Self::build_startup_message(params);
2804        backend
2805            .write_all(&startup_bytes)
2806            .await
2807            .map_err(|e| ProxyError::Network(format!("Backend startup write error: {}", e)))?;
2808
2809        // Forward authentication messages between client and backend.
2810        // Registers the backend's BackendKeyData so a later CancelRequest
2811        // can be routed back to this node.
2812        Self::proxy_authentication(client_stream, &mut backend, state, &node_addr).await?;
2813
2814        // Store session variables
2815        {
2816            let mut vars = session.variables.write().await;
2817            for (k, v) in params {
2818                vars.insert(k.clone(), v.clone());
2819            }
2820        }
2821
2822        Ok((Some(backend), node_addr))
2823    }
2824
2825    /// Build PostgreSQL startup message
2826    fn build_startup_message(params: &HashMap<String, String>) -> Vec<u8> {
2827        let mut payload = BytesMut::new();
2828
2829        // Protocol version 3.0
2830        payload.put_u32(196608);
2831
2832        // Parameters
2833        for (key, value) in params {
2834            payload.extend_from_slice(key.as_bytes());
2835            payload.put_u8(0);
2836            payload.extend_from_slice(value.as_bytes());
2837            payload.put_u8(0);
2838        }
2839        payload.put_u8(0); // Terminator
2840
2841        // Build complete message with length prefix
2842        let mut msg = BytesMut::new();
2843        msg.put_u32((payload.len() + 4) as u32);
2844        msg.extend_from_slice(&payload);
2845
2846        msg.to_vec()
2847    }
2848
2849    /// Cap on the cancel-key map; the oldest entries are evicted on overflow
2850    /// (a dropped stale entry only means one best-effort cancel is not
2851    /// forwarded).
2852    const MAX_CANCEL_KEYS: usize = 100_000;
2853
2854    /// Deadline for the pre-auth startup exchange (client TLS negotiation +
2855    /// PostgreSQL startup/authentication). A client that connects and then
2856    /// stalls must not hold a task and its session-map slot open forever
2857    /// (slow-loris). Only the handshake is bounded; the query loop is not.
2858    const STARTUP_TIMEOUT: Duration = Duration::from_secs(30);
2859    /// Timeout for a single backend write on the forward path — a blackholed or
2860    /// hung backend must never pin a client task indefinitely. Backend reads
2861    /// are already bounded (30s); this bounds writes symmetrically.
2862    const BACKEND_WRITE_TIMEOUT: Duration = Duration::from_secs(30);
2863    /// Timeout for a single client write — a wedged or very slow client must
2864    /// not pin a proxy task (and the backend connection it holds) forever.
2865    const CLIENT_WRITE_TIMEOUT: Duration = Duration::from_secs(60);
2866    /// Timeout for the out-of-band re-prepare exchange (write Parse+Flush, read
2867    /// ParseComplete) performed on a backend connection switch.
2868    const REPREPARE_TIMEOUT: Duration = Duration::from_secs(15);
2869    /// Per-session cap on distinct named prepared statements — bounds the
2870    /// `stmt_registry` against a client that issues unbounded `Parse`s.
2871    const MAX_PREPARED_STATEMENTS: usize = 8192;
2872    /// Per-session cap on the aggregate bytes retained in `stmt_registry`. The
2873    /// count cap alone does not bound memory: each entry holds the full encoded
2874    /// `Parse`, so 8192 large statements could still retain multiple GiB. This
2875    /// bounds the total held bytes (statements are tiny in practice; a session
2876    /// that approaches this is pathological).
2877    const MAX_PREPARED_BYTES: usize = 64 * 1024 * 1024;
2878    /// Per-session cap on the un-flushed extended-protocol `pending` buffer: a
2879    /// client must reach a Sync/Flush boundary before this many bytes pile up.
2880    const MAX_PENDING_BYTES: usize = 64 * 1024 * 1024;
2881    /// Global ceiling on idle connections parked in the data-path backend pool
2882    /// across ALL `(node,user,db)` identities — bounds total file descriptors
2883    /// regardless of how many distinct identities connect.
2884    #[cfg(feature = "pool-modes")]
2885    const MAX_TOTAL_IDLE_BACKEND_CONNS: usize = 8192;
2886    /// How often the idle-connection reaper runs.
2887    const POOL_REAP_INTERVAL: Duration = Duration::from_secs(30);
2888
2889    /// Record the backend that owns a BackendKeyData (pid, secret) pair.
2890    fn register_cancel_key(state: &Arc<ServerState>, pid: u32, key: u32, node_addr: &str) {
2891        // FIFO-evict the oldest registrations when at capacity, rather than
2892        // dropping all of them. Evict a small batch so we don't churn the lock
2893        // on every insert once full.
2894        {
2895            let mut order = state.cancel_order.lock();
2896            while state.cancel_map.len() >= Self::MAX_CANCEL_KEYS {
2897                match order.pop_front() {
2898                    Some(old) => {
2899                        state.cancel_map.remove(&old);
2900                    }
2901                    None => {
2902                        // Order queue empty but map full (shouldn't happen) —
2903                        // fall back to a clear to stay bounded.
2904                        state.cancel_map.clear();
2905                        break;
2906                    }
2907                }
2908            }
2909            order.push_back((pid, key));
2910        }
2911        state.cancel_map.insert((pid, key), node_addr.to_string());
2912    }
2913
2914    /// Forward a client CancelRequest to the backend that issued the
2915    /// matching BackendKeyData. Best-effort: unknown keys are ignored.
2916    async fn forward_cancel_request(state: &Arc<ServerState>, pid: u32, key: u32) {
2917        let Some(addr) = state.cancel_map.get(&(pid, key)).map(|e| e.clone()) else {
2918            tracing::debug!(pid, "cancel request for unknown key; ignoring");
2919            return;
2920        };
2921        // CancelRequest: int32 len(16) + int32 code(80877102) + pid + key.
2922        let mut msg = BytesMut::with_capacity(16);
2923        msg.put_u32(16);
2924        msg.put_u32(80877102);
2925        msg.put_u32(pid);
2926        msg.put_u32(key);
2927        match tokio::time::timeout(Duration::from_secs(5), TcpStream::connect(&addr)).await {
2928            Ok(Ok(mut conn)) => {
2929                let _ = conn.set_nodelay(true);
2930                if let Err(e) = conn.write_all(&msg).await {
2931                    tracing::warn!(node = %addr, error = %e, "failed to forward CancelRequest");
2932                }
2933                // PG closes the connection after handling a CancelRequest.
2934            }
2935            other => {
2936                tracing::warn!(node = %addr, ?other, "could not connect to forward CancelRequest")
2937            }
2938        }
2939    }
2940
2941    /// Proxy authentication messages between client and backend
2942    async fn proxy_authentication(
2943        client_stream: &mut ClientStream,
2944        backend_stream: &mut TcpStream,
2945        state: &Arc<ServerState>,
2946        node_addr: &str,
2947    ) -> Result<()> {
2948        // Bidirectional relay driven by readiness, not a fixed poll. The old
2949        // loop read the backend (untimed), forwarded, then polled the client
2950        // with a fixed 100ms window; a client that answered an auth challenge
2951        // more than 100ms after receiving it (WAN RTT, slow SCRAM client) missed
2952        // its window, and the loop then re-blocked on the untimed backend read
2953        // while the backend waited for the very response the proxy never read —
2954        // a deadlock until PostgreSQL's authentication_timeout killed it. Here
2955        // both directions are relayed as either side becomes readable, under one
2956        // overall deadline, so multi-round SCRAM completes regardless of client
2957        // latency.
2958        //
2959        // Backend-side frames are inspected by RAW tag (the wire decoder is
2960        // direction-agnostic — 'E' would decode to the client-side `Execute`,
2961        // not `ErrorResponse`), so a backend auth error is recognised here
2962        // rather than falling through to a misleading timeout.
2963        let mut backend_buffer = BytesMut::with_capacity(4096);
2964        let mut cbuf = vec![0u8; 4096];
2965        let mut bbuf = vec![0u8; 4096];
2966        let deadline = tokio::time::Instant::now() + Self::STARTUP_TIMEOUT;
2967
2968        loop {
2969            tokio::select! {
2970                biased;
2971                _ = tokio::time::sleep_until(deadline) => {
2972                    return Err(ProxyError::Auth(
2973                        "authentication timed out".to_string(),
2974                    ));
2975                }
2976                // Backend -> client: relay every byte, then scan complete frames
2977                // for the auth terminal states.
2978                r = backend_stream.read(&mut bbuf) => {
2979                    let n = r.map_err(|e| {
2980                        ProxyError::Network(format!("Backend auth read error: {}", e))
2981                    })?;
2982                    if n == 0 {
2983                        return Err(ProxyError::Connection(
2984                            "Backend closed during auth".to_string(),
2985                        ));
2986                    }
2987                    client_stream
2988                        .write_all(&bbuf[..n])
2989                        .await
2990                        .map_err(|e| ProxyError::Network(format!("Client auth write error: {}", e)))?;
2991                    backend_buffer.extend_from_slice(&bbuf[..n]);
2992
2993                    // Walk complete frames by raw tag.
2994                    loop {
2995                        if backend_buffer.len() < 5 {
2996                            break;
2997                        }
2998                        let len = u32::from_be_bytes([
2999                            backend_buffer[1],
3000                            backend_buffer[2],
3001                            backend_buffer[3],
3002                            backend_buffer[4],
3003                        ]) as usize;
3004                        if len < 4 || backend_buffer.len() < len + 1 {
3005                            break;
3006                        }
3007                        let tag = backend_buffer[0];
3008                        let frame = backend_buffer.split_to(len + 1);
3009                        match tag {
3010                            // BackendKeyData: 5-byte header + pid(4) + key(4).
3011                            // Remember which backend owns this cancel key.
3012                            b'K' if frame.len() >= 13 => {
3013                                let pid = u32::from_be_bytes([
3014                                    frame[5], frame[6], frame[7], frame[8],
3015                                ]);
3016                                let key = u32::from_be_bytes([
3017                                    frame[9], frame[10], frame[11], frame[12],
3018                                ]);
3019                                Self::register_cancel_key(state, pid, key, node_addr);
3020                            }
3021                            // ReadyForQuery: authentication + startup complete.
3022                            b'Z' => return Ok(()),
3023                            // ErrorResponse: auth failed (already relayed to the
3024                            // client above); surface the failure to the caller.
3025                            b'E' => {
3026                                return Err(ProxyError::Auth("Authentication failed".to_string()));
3027                            }
3028                            _ => {}
3029                        }
3030                    }
3031                }
3032                // Client -> backend: relay the client's auth response(s)
3033                // whenever they arrive, with no artificial deadline of their own.
3034                r = client_stream.read(&mut cbuf) => {
3035                    let n = r.map_err(|e| {
3036                        ProxyError::Network(format!("Client auth read error: {}", e))
3037                    })?;
3038                    if n == 0 {
3039                        return Err(ProxyError::Connection(
3040                            "Client closed during auth".to_string(),
3041                        ));
3042                    }
3043                    backend_stream
3044                        .write_all(&cbuf[..n])
3045                        .await
3046                        .map_err(|e| {
3047                            ProxyError::Network(format!("Backend password write error: {}", e))
3048                        })?;
3049                }
3050            }
3051        }
3052    }
3053
3054    /// Decide which node a request should be routed to, without doing any
3055    /// I/O. Reuses `current_node` when it is healthy and role-compatible
3056    /// (sticky session), otherwise selects a fresh primary/read node. The
3057    /// returned address is the key into the per-session connection cache.
3058    async fn choose_target_node(
3059        is_write: bool,
3060        forced_target: Option<String>,
3061        current_node: Option<&str>,
3062        session: &Arc<ClientSession>,
3063        state: &Arc<ServerState>,
3064        config: &ProxyConfig,
3065    ) -> Result<String> {
3066        // After a migration cutover, every request stays on the promoted
3067        // target — never route back to the former primary.
3068        if let Some(t) = state.cutover.load_full().as_ref() {
3069            return Ok(t.addr.clone());
3070        }
3071
3072        // Read-your-writes: within the window after a write, a read is pinned to
3073        // the primary (overriding the reuse-of-a-standby path) so the client
3074        // observes its own writes despite replica lag.
3075        #[cfg(feature = "lag-routing")]
3076        if !is_write && forced_target.is_none() && config.lag_routing.enabled {
3077            let last_write = *session.last_write_at.read().await;
3078            if Self::ryw_pins_primary(last_write, config.lag_routing.ryw_window_ms) {
3079                tracing::debug!(target: "helios::routing", "read-your-writes: pinning read to primary");
3080                return Self::select_primary_with_timeout(session, state, config).await;
3081            }
3082        }
3083
3084        let need_switch = if let Some(ref forced) = forced_target {
3085            let health = state.health.load_full();
3086            let reuse = current_node
3087                .map(|c| c == forced && health.get(c).map(|h| h.healthy).unwrap_or(false))
3088                .unwrap_or(false);
3089            !reuse
3090        } else if let Some(current) = current_node {
3091            let health = state.health.load_full();
3092            let current_healthy = health.get(current).map(|h| h.healthy).unwrap_or(false);
3093            if !current_healthy {
3094                true
3095            } else if is_write {
3096                let is_primary = config
3097                    .nodes
3098                    .iter()
3099                    .find(|n| n.address() == *current)
3100                    .map(|n| n.role == NodeRole::Primary)
3101                    .unwrap_or(false);
3102                !is_primary
3103            } else {
3104                false
3105            }
3106        } else {
3107            true
3108        };
3109
3110        if let Some(forced) = forced_target {
3111            // Resolve a node *name* to its address; an address is passed
3112            // through unchanged. This lets `/*helios:node=pg-standby*/` (and a
3113            // plugin `Node("name")`) target a node by its configured name
3114            // rather than requiring the raw host:port.
3115            let resolved = config
3116                .nodes
3117                .iter()
3118                .find(|n| n.name.as_deref() == Some(forced.as_str()) || n.address() == forced)
3119                .map(|n| n.address())
3120                .unwrap_or(forced);
3121            Ok(resolved)
3122        } else if need_switch {
3123            if is_write {
3124                Self::select_primary_with_timeout(session, state, config).await
3125            } else {
3126                Self::select_read_node(session, state, config).await
3127            }
3128        } else {
3129            Ok(current_node.unwrap().to_string())
3130        }
3131    }
3132
3133    /// Ensure the per-session cache holds an authenticated backend connection
3134    /// to `target`, dialing + silently re-authenticating one (with the
3135    /// client's pass-through credentials) only if absent. The cached
3136    /// connection is then reused across read/write route switches.
3137    async fn ensure_conn(
3138        conns: &mut HashMap<String, BackendConn>,
3139        target: &str,
3140        session: &Arc<ClientSession>,
3141        config: &ProxyConfig,
3142        _state: &Arc<ServerState>,
3143    ) -> Result<()> {
3144        if conns.contains_key(target) {
3145            return Ok(());
3146        }
3147
3148        // Transaction/Statement pooling: lease a parked, identity-matched
3149        // connection before paying for a fresh TCP connect + startup + auth.
3150        // The parked connection was `DISCARD ALL`-reset on release, so it is
3151        // clean for this (same-identity) client.
3152        #[cfg(feature = "pool-modes")]
3153        if let Some(pool) = _state.backend_pool.as_ref() {
3154            let key = Self::pool_key_for(target, session).await;
3155            if let Some(stream) = pool.checkout(&key) {
3156                tracing::info!(
3157                    target: "helios::pool",
3158                    node = %target,
3159                    "reused pooled backend connection"
3160                );
3161                conns.insert(target.to_string(), BackendConn::new(stream));
3162                return Ok(());
3163            }
3164        }
3165
3166        let mut backend =
3167            tokio::time::timeout(config.pool.acquire_timeout(), TcpStream::connect(target))
3168                .await
3169                .map_err(|_| ProxyError::Connection(format!("Connection timeout to {}", target)))?
3170                .map_err(|e| {
3171                    ProxyError::Connection(format!("Failed to connect to {}: {}", target, e))
3172                })?;
3173        let _ = backend.set_nodelay(true);
3174
3175        let params = session.variables.read().await.clone();
3176        let startup = Self::build_startup_message(&params);
3177        backend
3178            .write_all(&startup)
3179            .await
3180            .map_err(|e| ProxyError::Network(format!("Backend startup error: {}", e)))?;
3181        Self::complete_backend_auth(&mut backend).await?;
3182        #[cfg(feature = "pool-modes")]
3183        if _state.backend_pool.is_some() {
3184            tracing::debug!(target: "helios::pool", node = %target, "dialed fresh backend connection (pool miss)");
3185        }
3186        tracing::debug!(node = %target, "opened backend connection");
3187        conns.insert(target.to_string(), BackendConn::new(backend));
3188        Ok(())
3189    }
3190
3191    /// Startup parameters that change how the backend interprets or renders
3192    /// values and are reset by `DISCARD ALL`/`RESET ALL` to the connection's
3193    /// *startup* values. Two clients of the same `(node,user,db)` but different
3194    /// values for any of these must NOT share a pooled connection, or the
3195    /// borrower silently inherits the lender's encoding/date/number formatting.
3196    /// This mirrors PgBouncer's `track_extra_parameters` intent.
3197    #[cfg(feature = "pool-modes")]
3198    const POOL_IDENTITY_PARAMS: &'static [&'static str] = &[
3199        "client_encoding",
3200        "DateStyle",
3201        "TimeZone",
3202        "IntervalStyle",
3203        "standard_conforming_strings",
3204        "options",
3205    ];
3206
3207    /// Build the pool key for the current session's connection identity.
3208    /// Base identity is `(node, user, database)` — connections are reused only
3209    /// within an identity, so a borrower always matches the principal the parked
3210    /// connection was authenticated as. When any routing-relevant startup GUC
3211    /// (see `POOL_IDENTITY_PARAMS`) is set, a hash of those is folded in so the
3212    /// borrower also matches the lender's value-formatting settings. The common
3213    /// case (no custom GUCs) keeps the bare `(node,user,db)` key unchanged.
3214    #[cfg(feature = "pool-modes")]
3215    async fn pool_key_for(target: &str, session: &Arc<ClientSession>) -> String {
3216        let vars = session.variables.read().await;
3217        let user = vars.get("user").map(|s| s.as_str()).unwrap_or("");
3218        // PostgreSQL defaults the database to the role name when unset.
3219        let database = vars.get("database").map(|s| s.as_str()).unwrap_or(user);
3220        let base = crate::pool::pool_key(target, user, database);
3221
3222        use std::hash::{Hash, Hasher};
3223        let mut h = std::collections::hash_map::DefaultHasher::new();
3224        let mut any = false;
3225        for k in Self::POOL_IDENTITY_PARAMS {
3226            if let Some(v) = vars.get(*k) {
3227                any = true;
3228                k.hash(&mut h);
3229                v.hash(&mut h);
3230            }
3231        }
3232        if any {
3233            format!("{}\0{:016x}", base, h.finish())
3234        } else {
3235            base
3236        }
3237    }
3238
3239    /// Reset a backend connection to a clean session state before parking it
3240    /// for reuse: runs the configured reset query (default `DISCARD ALL`,
3241    /// which deallocates prepared statements, drops temp tables, resets GUCs
3242    /// and advisory locks) and drains its response to `ReadyForQuery`. Returns
3243    /// `Err` if the connection is unhealthy OR the reset itself did not cleanly
3244    /// succeed — the caller then drops (closes) it instead of parking a
3245    /// poisoned connection.
3246    ///
3247    /// "Cleanly succeeded" means: no `ErrorResponse` frame in the reply AND the
3248    /// terminating `ReadyForQuery` reported idle (`'I'`, not in/failed
3249    /// transaction). The previous version returned `Ok` on the first
3250    /// `ReadyForQuery` regardless, so a failed `DISCARD ALL` (e.g. a copy-abort,
3251    /// or a custom `reset_query` that errors) would park a connection with its
3252    /// GUCs / temp tables / prepared statements intact — exactly what pooling
3253    /// must never do. Frames are walked by raw tag because the wire decoder is
3254    /// direction-agnostic (`'E'` decodes to the client-side `Execute`), so a
3255    /// backend `ErrorResponse` cannot be recognised via `msg_type`.
3256    #[cfg(feature = "pool-modes")]
3257    async fn reset_backend<S: AsyncReadExt + AsyncWriteExt + Unpin>(
3258        stream: &mut S,
3259        reset_sql: &str,
3260    ) -> Result<()> {
3261        let msg = crate::protocol::QueryMessage {
3262            query: reset_sql.to_string(),
3263        }
3264        .encode();
3265        tokio::time::timeout(Self::BACKEND_WRITE_TIMEOUT, stream.write_all(&msg.encode()))
3266            .await
3267            .map_err(|_| ProxyError::Network("reset write timeout".to_string()))?
3268            .map_err(|e| ProxyError::Network(format!("reset write error: {}", e)))?;
3269
3270        let mut buf = BytesMut::with_capacity(1024);
3271        let mut had_error = false;
3272        loop {
3273            // Walk complete frames by raw tag, tracking any ErrorResponse and
3274            // stopping at ReadyForQuery.
3275            let mut consumed = 0usize;
3276            let mut ready_status: Option<u8> = None;
3277            loop {
3278                let rem = &buf[consumed..];
3279                if rem.len() < 5 {
3280                    break;
3281                }
3282                let len = u32::from_be_bytes([rem[1], rem[2], rem[3], rem[4]]) as usize;
3283                if len < 4 || rem.len() < len + 1 {
3284                    break;
3285                }
3286                let mtype = rem[0];
3287                let frame_total = len + 1;
3288                if mtype == b'E' {
3289                    had_error = true;
3290                }
3291                consumed += frame_total;
3292                if mtype == b'Z' {
3293                    ready_status = Some(if frame_total >= 6 { rem[5] } else { b'I' });
3294                    break;
3295                }
3296            }
3297            let _ = buf.split_to(consumed);
3298
3299            if let Some(status) = ready_status {
3300                if had_error || status != b'I' {
3301                    return Err(ProxyError::Connection(format!(
3302                        "reset query did not cleanly succeed (error={}, status={})",
3303                        had_error, status as char
3304                    )));
3305                }
3306                return Ok(());
3307            }
3308
3309            buf.reserve(1024);
3310            let n = tokio::time::timeout(Duration::from_secs(5), stream.read_buf(&mut buf))
3311                .await
3312                .map_err(|_| ProxyError::Network("reset drain timeout".to_string()))?
3313                .map_err(|e| ProxyError::Network(format!("reset drain read error: {}", e)))?;
3314            if n == 0 {
3315                return Err(ProxyError::Connection(
3316                    "backend closed during reset".to_string(),
3317                ));
3318            }
3319        }
3320    }
3321
3322    /// Transaction/Statement pooling release point: when the session is at an
3323    /// idle boundary (`ReadyForQuery` reported not-in-transaction), reset the
3324    /// just-used connection and park it for reuse by the next same-identity
3325    /// client. A no-op in Session mode or when the feature is off. Never
3326    /// releases mid-transaction.
3327    #[cfg(feature = "pool-modes")]
3328    async fn release_to_pool_if_idle(
3329        conns: &mut HashMap<String, BackendConn>,
3330        node: Option<&str>,
3331        session: &Arc<ClientSession>,
3332        state: &Arc<ServerState>,
3333        config: &ProxyConfig,
3334    ) {
3335        let Some(pool) = state.backend_pool.as_ref() else {
3336            return;
3337        };
3338        let Some(node) = node else {
3339            return;
3340        };
3341        // Only release at a clean transaction boundary — never mid-transaction
3342        // and never mid-COPY (the backend is awaiting CopyData; resetting +
3343        // parking the socket now aborts the copy and hangs the client).
3344        if session
3345            .in_transaction
3346            .load(std::sync::atomic::Ordering::Relaxed)
3347            || session
3348                .copy_in_progress
3349                .load(std::sync::atomic::Ordering::Relaxed)
3350        {
3351            return;
3352        }
3353        let Some(mut bc) = conns.remove(node) else {
3354            return;
3355        };
3356
3357        // Conditional reset: a connection that provably touched no session
3358        // state — no dirtying simple statement, no named prepared statement, no
3359        // unnamed prepared statement — can be parked WITHOUT the `DISCARD ALL`
3360        // round-trip, removing a backend RTT from the critical path for clean
3361        // autocommit workloads. Any of these three signals forces the full
3362        // reset. Extended-protocol traffic always has `prepared`/`unnamed_sig`
3363        // set, so it is never clean-skipped (conservative by construction).
3364        let clean = !bc.dirty && bc.prepared.is_empty() && bc.unnamed_sig.is_none();
3365        if config.pool_mode.skip_clean_reset && clean {
3366            let key = Self::pool_key_for(node, session).await;
3367            if pool.checkin(&key, bc.stream) {
3368                pool.note_reset_skipped();
3369                tracing::debug!(target: "helios::pool", node = %node, "parked clean backend connection (reset skipped)");
3370            }
3371            return;
3372        }
3373
3374        if Self::reset_backend(&mut bc.stream, &config.pool_mode.reset_query)
3375            .await
3376            .is_ok()
3377        {
3378            let key = Self::pool_key_for(node, session).await;
3379            if pool.checkin(&key, bc.stream) {
3380                tracing::debug!(target: "helios::pool", node = %node, "parked backend connection for reuse");
3381            }
3382        }
3383        // On reset failure the connection is dropped here (closed).
3384    }
3385
3386    /// Forward a simple-query (`Query`) message and stream its response back
3387    /// to the client frame-by-frame, ending at ReadyForQuery. Picks (and, if
3388    /// needed, opens) the target node's connection from the per-session
3389    /// cache. Returns `(Some(node_used), bytes)` — `None` node means the
3390    /// request was short-circuited (plugin block) without touching a backend.
3391    async fn forward_simple_query(
3392        client: &mut ClientStream,
3393        msg: &Message,
3394        conns: &mut HashMap<String, BackendConn>,
3395        current_node: Option<&str>,
3396        session: &Arc<ClientSession>,
3397        state: &Arc<ServerState>,
3398        config: &ProxyConfig,
3399    ) -> Result<(Option<String>, u64)> {
3400        // Rate-limit gate: deny before any backend selection.
3401        #[cfg(feature = "rate-limiting")]
3402        if let Some(mut resp) = Self::rate_limit_check(session, state, config).await {
3403            resp.extend_from_slice(&Self::create_ready_for_query(b'I'));
3404            client
3405                .write_all(&resp)
3406                .await
3407                .map_err(|e| ProxyError::Network(format!("Client write error: {}", e)))?;
3408            return Ok((None, resp.len() as u64));
3409        }
3410
3411        let default_is_write = Self::is_write_message(msg);
3412        let plugin_override = Self::apply_route_hook(msg, state, session);
3413
3414        // Block short-circuits before any backend selection.
3415        if let RouteOverride::Block(reason) = plugin_override {
3416            let mut response = Vec::with_capacity(64 + reason.len());
3417            response.extend_from_slice(&Self::create_error_response(
3418                "42000",
3419                &format!("Query blocked by route plugin: {}", reason),
3420            ));
3421            response.extend_from_slice(&Self::create_ready_for_query(b'I'));
3422            client
3423                .write_all(&response)
3424                .await
3425                .map_err(|e| ProxyError::Network(format!("Client write error: {}", e)))?;
3426            return Ok((None, response.len() as u64));
3427        }
3428
3429        // SQL-comment routing hints (feature + `[routing_hints] enabled`)
3430        // refine the override, recompute the write flag on the stripped SQL,
3431        // and may rewrite the message to drop the hint comment.
3432        #[cfg(feature = "routing-hints")]
3433        let (route_override, default_is_write, stripped_msg) =
3434            Self::resolve_simple_route(msg, plugin_override, default_is_write, state);
3435        #[cfg(not(feature = "routing-hints"))]
3436        let (route_override, stripped_msg): (RouteOverride, Option<Message>) =
3437            (plugin_override, None);
3438
3439        let (is_write, forced_target) = match route_override {
3440            RouteOverride::None => (default_is_write, None),
3441            RouteOverride::Primary => (true, None),
3442            RouteOverride::Standby => (false, None),
3443            RouteOverride::Node(name) => (default_is_write, Some(name)),
3444            RouteOverride::Block(_) => unreachable!("handled above"),
3445        };
3446
3447        // Read-your-writes: stamp the session on a write so subsequent reads
3448        // pin to the primary for the configured window.
3449        #[cfg(feature = "lag-routing")]
3450        if is_write && config.lag_routing.enabled {
3451            *session.last_write_at.write().await = Some(std::time::Instant::now());
3452        }
3453
3454        // Forward the stripped message when routing-hints rewrote it, else the
3455        // original (borrowed, no copy).
3456        let forward_msg = stripped_msg.as_ref().unwrap_or(msg);
3457
3458        // Query rewriting: apply rules to the SQL; if any rule fired, forward a
3459        // rebuilt Query carrying the rewritten SQL (so caching + the backend
3460        // both see the rewritten form).
3461        #[cfg(feature = "query-rewriting")]
3462        let rewritten_msg: Option<Message> = state.rewriter.as_ref().and_then(|rw| {
3463            let sql = crate::protocol::query_text(&forward_msg.payload)?;
3464            match rw.rewrite(sql) {
3465                Ok(res) if res.was_rewritten() => {
3466                    tracing::debug!(target: "helios::rewrite", rules = ?res.rules_applied, "query rewritten");
3467                    Some(crate::protocol::QueryMessage { query: res.query().to_string() }.encode())
3468                }
3469                _ => None,
3470            }
3471        });
3472        #[cfg(feature = "query-rewriting")]
3473        let forward_msg = rewritten_msg.as_ref().unwrap_or(forward_msg);
3474
3475        // Multi-tenancy: resolve the session's tenant and inject a row-level
3476        // tenant filter. Done BEFORE the cache lookup so each tenant's results
3477        // are cached under their own (filtered) SQL — no cross-tenant leakage.
3478        #[cfg(feature = "multi-tenancy")]
3479        let tenant_msg: Option<Message> = if let Some(tm) = state.tenant_manager.as_ref() {
3480            match crate::protocol::query_text(&forward_msg.payload) {
3481                Some(sql) => {
3482                    let ctx = Self::tenant_request_ctx(session).await;
3483                    match tm.identify_tenant(&ctx) {
3484                        Some(tenant) => {
3485                            let res = tm.transform_query(sql, &tenant);
3486                            if res.transformed {
3487                                tracing::debug!(target: "helios::tenant", tenant = %tenant.0, "tenant filter injected");
3488                                Some(crate::protocol::QueryMessage { query: res.query }.encode())
3489                            } else {
3490                                None
3491                            }
3492                        }
3493                        None => None,
3494                    }
3495                }
3496                None => None,
3497            }
3498        } else {
3499            None
3500        };
3501        #[cfg(feature = "multi-tenancy")]
3502        let forward_msg = tenant_msg.as_ref().unwrap_or(forward_msg);
3503
3504        // Edge cache: independent of the query-cache below — when both are
3505        // enabled the edge lookup runs first, so an edge hit returns before
3506        // the query-cache is consulted. On a miss the read's stamp/epoch is
3507        // taken BEFORE forwarding, so the store gate can reject the store if
3508        // an invalidation lands while the read is in flight.
3509        //
3510        // Session-state stickiness (F2): the shared cache cannot model
3511        // session-local execution context (SET search_path / SET ROLE / RLS
3512        // GUCs / temp objects). Any statement that leaves such state makes
3513        // the session permanently ineligible for edge lookup AND store —
3514        // conservative, but the alternative is cross-session wrong rows.
3515        #[cfg(feature = "edge-proxy")]
3516        if config.edge.enabled
3517            && !session
3518                .edge_ineligible
3519                .load(std::sync::atomic::Ordering::Relaxed)
3520        {
3521            if let Some(sql) = crate::protocol::query_text(&forward_msg.payload) {
3522                if Self::stmt_leaves_session_state(sql) {
3523                    session
3524                        .edge_ineligible
3525                        .store(true, std::sync::atomic::Ordering::Relaxed);
3526                }
3527            }
3528        }
3529        #[cfg(feature = "edge-proxy")]
3530        let edge_ctx: Option<(crate::edge::CacheKey, Vec<String>, u64, u64)> = if is_write
3531            || !config.edge.enabled
3532            || session
3533                .edge_ineligible
3534                .load(std::sync::atomic::Ordering::Relaxed)
3535        {
3536            None
3537        } else {
3538            let sql = crate::protocol::query_text(&forward_msg.payload).unwrap_or("");
3539            // Same gate as the query-cache: a plain deterministic SELECT, and
3540            // never mid-transaction (visibility would be wrong).
3541            if Self::is_cacheable_read_sql(sql)
3542                && !session
3543                    .in_transaction
3544                    .load(std::sync::atomic::Ordering::Relaxed)
3545            {
3546                // Tenant identity + result-affecting startup params
3547                // (TimeZone, options=-c..., extra_float_digits, ...) all
3548                // partition the key: identical SQL under a different
3549                // session environment must not share a slot.
3550                let (database, user, session_vars) = {
3551                    let vars = session.variables.read().await;
3552                    let database = vars
3553                        .get("database")
3554                        .cloned()
3555                        .unwrap_or_else(|| "default".to_string());
3556                    let user = vars.get("user").cloned().unwrap_or_default();
3557                    let mut rest: Vec<(String, String)> = vars
3558                        .iter()
3559                        .filter(|(k, _)| k.as_str() != "database" && k.as_str() != "user")
3560                        .map(|(k, v)| (k.clone(), v.clone()))
3561                        .collect();
3562                    rest.sort();
3563                    (database, user, rest)
3564                };
3565                let fp = crate::edge::fingerprint::analyze(sql, &database, &user, &session_vars);
3566                // A read with no extractable tables could never be dropped by
3567                // a table-targeted invalidation — never cache it (coherence
3568                // rule).
3569                if fp.tables.is_empty() {
3570                    None
3571                } else {
3572                    let key = crate::edge::CacheKey {
3573                        fingerprint: fp.fingerprint,
3574                        params_hash: fp.params_hash,
3575                        database,
3576                        user,
3577                    };
3578                    if let Some(entry) = state.edge_cache.get(&key) {
3579                        tracing::debug!(target: "helios::edge", "edge cache hit");
3580                        client.write_all(&entry.response_bytes).await.map_err(|e| {
3581                            ProxyError::Network(format!("Client write error: {}", e))
3582                        })?;
3583                        return Ok((None, entry.response_bytes.len() as u64));
3584                    }
3585                    // Miss: stamp the read before it reaches a backend, in
3586                    // the INVALIDATION clock's domain. Home role: mint from
3587                    // the local counter (the same clock that versions
3588                    // writes). Edge role: writes are versioned by the HOME,
3589                    // so stamp with the last observed home version — any
3590                    // later home write mints a strictly greater version and
3591                    // always sweeps this entry; the store race is closed by
3592                    // the invalidation-epoch snapshot instead of the hwm.
3593                    let (read_version, inval_epoch) =
3594                        if config.edge.role == crate::edge::EdgeRole::Edge {
3595                            (
3596                                state.edge_cache.observed_home_version(),
3597                                state.edge_cache.invalidation_epoch(),
3598                            )
3599                        } else {
3600                            (state.edge_cache.next_version(), 0)
3601                        };
3602                    Some((key, fp.tables, read_version, inval_epoch))
3603                }
3604            } else {
3605                None
3606            }
3607        };
3608
3609        // Query cache: on a cacheable read, a hit is served from cache with no
3610        // backend round-trip; on a miss we keep the context to store the result.
3611        #[cfg(feature = "query-cache")]
3612        let cache_ctx: Option<crate::cache::CacheContext> = if is_write {
3613            None
3614        } else if let Some(qc) = state.query_cache.as_ref() {
3615            let sql = crate::protocol::query_text(&forward_msg.payload).unwrap_or("");
3616            match Self::cacheable_read_ctx(session, sql).await {
3617                Some(ctx) => {
3618                    if let crate::cache::CacheLookup::Hit { result, level } =
3619                        qc.get(sql, &ctx).await
3620                    {
3621                        tracing::debug!(target: "helios::cache", level = %level, "cache hit");
3622                        client.write_all(&result.data).await.map_err(|e| {
3623                            ProxyError::Network(format!("Client write error: {}", e))
3624                        })?;
3625                        return Ok((None, result.data.len() as u64));
3626                    }
3627                    Some(ctx)
3628                }
3629                None => None,
3630            }
3631        } else {
3632            None
3633        };
3634
3635        // Schema/workload routing: pin an analytical (OLAP) read to the
3636        // configured analytics node, unless something already forced a target.
3637        #[cfg(feature = "schema-routing")]
3638        let forced_target = match state.schema_analyzer.as_ref() {
3639            Some(analyzer)
3640                if forced_target.is_none()
3641                    && !is_write
3642                    && !config.schema_routing.analytics_node.is_empty() =>
3643            {
3644                match crate::protocol::query_text(&forward_msg.payload) {
3645                    Some(sql) if analyzer.analyze(sql).is_analytics() => {
3646                        tracing::debug!(target: "helios::schema", "OLAP query routed to analytics node");
3647                        Some(config.schema_routing.analytics_node.clone())
3648                    }
3649                    _ => forced_target,
3650                }
3651            }
3652            _ => forced_target,
3653        };
3654
3655        // Analytics: capture the forwarded SQL + start the latency timer.
3656        #[cfg(feature = "query-analytics")]
3657        let analytics_sql =
3658            crate::protocol::query_text(&forward_msg.payload).map(|s| s.to_string());
3659        #[cfg(feature = "query-analytics")]
3660        let started = std::time::Instant::now();
3661
3662        let target = Self::choose_target_node(
3663            is_write,
3664            forced_target,
3665            current_node,
3666            session,
3667            state,
3668            config,
3669        )
3670        .await?;
3671        tracing::debug!(target: "helios::routing", node = %target, is_write, "routed simple query");
3672
3673        // Circuit breaker: fast-fail when the chosen node's circuit is open.
3674        #[cfg(feature = "circuit-breaker")]
3675        if let Some(mut resp) = Self::circuit_fast_fail(state, &target) {
3676            resp.extend_from_slice(&Self::create_ready_for_query(b'I'));
3677            client
3678                .write_all(&resp)
3679                .await
3680                .map_err(|e| ProxyError::Network(format!("Client write error: {}", e)))?;
3681            return Ok((None, resp.len() as u64));
3682        }
3683
3684        // A connect/auth failure trips the breaker (and is propagated as today).
3685        if let Err(e) = Self::ensure_conn(conns, &target, session, config, state).await {
3686            Self::record_backend_failure(state, &target, &e.to_string());
3687            return Err(e);
3688        }
3689        let backend = conns.get_mut(&target).expect("just ensured");
3690
3691        // Conditional-reset bookkeeping: if this statement is not provably
3692        // session-neutral, mark the connection dirty so it is fully reset (not
3693        // clean-skipped) when parked. Only evaluated when the optimisation is
3694        // enabled and the connection is not already dirty (one O(len) scan at
3695        // most, until the first dirtying statement).
3696        #[cfg(feature = "pool-modes")]
3697        if config.pool_mode.skip_clean_reset && !backend.dirty {
3698            if let Some(sql) = crate::protocol::query_text(&forward_msg.payload) {
3699                if Self::stmt_leaves_session_state(sql) {
3700                    backend.dirty = true;
3701                }
3702            }
3703        }
3704
3705        let backend_err = match tokio::time::timeout(
3706            Self::BACKEND_WRITE_TIMEOUT,
3707            backend.stream.write_all(&forward_msg.encode()),
3708        )
3709        .await
3710        {
3711            Ok(Ok(())) => None,
3712            Ok(Err(e)) => Some(format!("Backend write error: {}", e)),
3713            Err(_) => Some("Backend write timeout".to_string()),
3714        };
3715        if let Some(msg) = backend_err {
3716            let e = ProxyError::Network(msg);
3717            conns.remove(&target);
3718            Self::record_backend_failure(state, &target, &e.to_string());
3719            return Err(e);
3720        }
3721
3722        // Cacheable read miss (query-cache and/or edge cache): capture the
3723        // response frames ONCE and store them so a later identical read is
3724        // served from cache without a backend hit. Both caches share the one
3725        // captured buffer — never capture twice.
3726        #[cfg(any(feature = "query-cache", feature = "edge-proxy"))]
3727        {
3728            #[cfg(feature = "query-cache")]
3729            let qc_wants = cache_ctx.is_some() && state.query_cache.is_some();
3730            #[cfg(not(feature = "query-cache"))]
3731            let qc_wants = false;
3732            #[cfg(feature = "edge-proxy")]
3733            let edge_wants = edge_ctx.is_some();
3734            #[cfg(not(feature = "edge-proxy"))]
3735            let edge_wants = false;
3736            if qc_wants || edge_wants {
3737                return match Self::stream_until_ready_capture(client, &mut backend.stream, session)
3738                    .await
3739                {
3740                    Ok((sent, captured, cacheable, rows)) => {
3741                        #[cfg(not(feature = "query-cache"))]
3742                        let _ = rows; // row count only feeds the query-cache
3743                        #[cfg(feature = "circuit-breaker")]
3744                        Self::circuit_record(state, &target, true, "");
3745                        // One zero-copy conversion; both caches then share the
3746                        // refcounted buffer (no per-miss memcpy, F20).
3747                        let body = bytes::Bytes::from(captured);
3748                        // Edge store. The race-checked insert variants re-verify
3749                        // the gate UNDER the map lock: a concurrent invalidation
3750                        // that completed between the read and this store must
3751                        // reject it (read-after-invalidate TOCTOU, F18). Home
3752                        // role gates on the version hwm; edge role on the
3753                        // invalidation-epoch snapshot taken before forwarding.
3754                        #[cfg(feature = "edge-proxy")]
3755                        if let Some((key, tables, read_version, inval_epoch)) = edge_ctx {
3756                            if cacheable && !body.is_empty() {
3757                                let entry = crate::edge::CacheEntry {
3758                                    version: read_version,
3759                                    response_bytes: body.clone(),
3760                                    tables,
3761                                    expires_at: std::time::Instant::now()
3762                                        + config.edge.default_ttl(),
3763                                };
3764                                let stored = if config.edge.role == crate::edge::EdgeRole::Edge {
3765                                    state.edge_cache.insert_if_epoch(key, entry, inval_epoch)
3766                                } else {
3767                                    state.edge_cache.insert_if_fresh(key, entry)
3768                                };
3769                                if !stored {
3770                                    tracing::debug!(
3771                                        target: "helios::edge",
3772                                        "edge store skipped — invalidation raced the read"
3773                                    );
3774                                }
3775                            }
3776                        }
3777                        #[cfg(feature = "query-cache")]
3778                        if let (Some(ctx), Some(qc)) =
3779                            (cache_ctx.as_ref(), state.query_cache.as_ref())
3780                        {
3781                            if cacheable && !body.is_empty() {
3782                                let sql =
3783                                    crate::protocol::query_text(&forward_msg.payload).unwrap_or("");
3784                                qc.put(sql, ctx, body.clone(), rows, std::time::Duration::ZERO)
3785                                    .await;
3786                            }
3787                        }
3788                        let _ = body;
3789                        #[cfg(feature = "query-analytics")]
3790                        if let Some(sql) = analytics_sql.as_deref() {
3791                            Self::record_analytics(
3792                                state,
3793                                session,
3794                                sql,
3795                                &target,
3796                                started.elapsed(),
3797                                None,
3798                            )
3799                            .await;
3800                        }
3801                        Ok((Some(target), sent))
3802                    }
3803                    Err(e) => {
3804                        conns.remove(&target);
3805                        Self::record_backend_failure(state, &target, &e.to_string());
3806                        Err(e)
3807                    }
3808                };
3809            }
3810        }
3811
3812        match Self::stream_until_ready(client, &mut backend.stream, session, state).await {
3813            Ok(sent) => {
3814                #[cfg(feature = "circuit-breaker")]
3815                Self::circuit_record(state, &target, true, "");
3816                // Invalidate cached reads referencing tables this write touched.
3817                #[cfg(feature = "query-cache")]
3818                if is_write {
3819                    if let Some(qc) = state.query_cache.as_ref() {
3820                        let sql = crate::protocol::query_text(&forward_msg.payload).unwrap_or("");
3821                        qc.invalidate_query(sql).await;
3822                    }
3823                }
3824                // Edge cache: drop local entries for the touched tables and
3825                // (home role only) fan the invalidation out to every
3826                // registered edge over SSE. Also fires for SELECT-leading
3827                // multi-statement strings (the trailing write would otherwise
3828                // invalidate nothing); bare transaction-control statements
3829                // (BEGIN/START/SAVEPOINT/RELEASE/ROLLBACK) change no rows and
3830                // are exempted — but COMMIT and SET keep their conservative
3831                // full flush. A `COPY ... FROM` is deferred to its CopyDone
3832                // drain (rows become visible then).
3833                #[cfg(feature = "edge-proxy")]
3834                if config.edge.enabled {
3835                    let sql = crate::protocol::query_text(&forward_msg.payload).unwrap_or("");
3836                    if Self::edge_write_needs_invalidation(is_write, sql) {
3837                        // `tables_only` skips the fingerprint/params-hash work
3838                        // (discarded here) — a bulk INSERT must not pay full-
3839                        // buffer regex rewrites on the forward path. An EMPTY
3840                        // table set invalidates everything at or below the
3841                        // version — an unparseable write must not leave stale
3842                        // entries behind (coherence rule).
3843                        let tables = crate::edge::fingerprint::tables_only(sql);
3844                        if session
3845                            .copy_in_progress
3846                            .load(std::sync::atomic::Ordering::Relaxed)
3847                        {
3848                            *session
3849                                .pending_edge_copy_tables
3850                                .lock()
3851                                .unwrap_or_else(|e| e.into_inner()) = Some(tables);
3852                        } else {
3853                            Self::edge_invalidate_write(state, config, tables).await;
3854                        }
3855                    }
3856                }
3857                // Transaction Replay: journal the write for failover/time-travel.
3858                #[cfg(feature = "ha-tr")]
3859                if is_write && config.tr_enabled {
3860                    if let Some(sql) = crate::protocol::query_text(&forward_msg.payload) {
3861                        Self::journal_write(state, session, sql).await;
3862                    }
3863                }
3864                #[cfg(feature = "query-analytics")]
3865                if let Some(sql) = analytics_sql.as_deref() {
3866                    Self::record_analytics(state, session, sql, &target, started.elapsed(), None)
3867                        .await;
3868                }
3869                Ok((Some(target), sent))
3870            }
3871            Err(e) => {
3872                // Drop the broken connection so the next use redials.
3873                conns.remove(&target);
3874                Self::record_backend_failure(state, &target, &e.to_string());
3875                #[cfg(feature = "query-analytics")]
3876                if let Some(sql) = analytics_sql.as_deref() {
3877                    Self::record_analytics(
3878                        state,
3879                        session,
3880                        sql,
3881                        &target,
3882                        started.elapsed(),
3883                        Some(e.to_string()),
3884                    )
3885                    .await;
3886                }
3887                Err(e)
3888            }
3889        }
3890    }
3891
3892    /// Forward an accumulated extended-protocol batch (Parse/Bind/Describe/
3893    /// Execute/Close terminated by Sync or Flush) and stream the response.
3894    /// Routing is taken from `route_sql` (the first Parse's SQL); when it is
3895    /// `None` (a re-Bind/Execute of a named prepared statement) the request
3896    /// stays on the connection the statement was prepared on — no switch.
3897    ///
3898    /// `reprepare` lists named statements this batch references but does not
3899    /// itself define; any that the chosen connection has not seen are
3900    /// re-prepared from `registry` (their original `Parse`) before the batch is
3901    /// sent, so a named statement survives a backend switch/redial (Batch F.4).
3902    /// `defines` are the named statements this batch's own `Parse`s create —
3903    /// recorded against the connection once it accepts the batch.
3904    #[allow(clippy::too_many_arguments)]
3905    async fn forward_extended_batch(
3906        client: &mut ClientStream,
3907        batch: &[u8],
3908        route_sql: Option<&str>,
3909        wait_ready: bool,
3910        conns: &mut HashMap<String, BackendConn>,
3911        current_node: Option<&str>,
3912        registry: &HashMap<String, bytes::Bytes>,
3913        reprepare: &[String],
3914        defines: &[String],
3915        unnamed: Option<(bytes::Bytes, bytes::Bytes)>,
3916        session: &Arc<ClientSession>,
3917        state: &Arc<ServerState>,
3918        config: &ProxyConfig,
3919    ) -> Result<(Option<String>, u64)> {
3920        // Rate-limit gate. The terminating ReadyForQuery is only appended when
3921        // the batch carried a Sync (`wait_ready`); a Flush-terminated batch
3922        // expects an ErrorResponse with no ReadyForQuery.
3923        #[cfg(feature = "rate-limiting")]
3924        if let Some(mut resp) = Self::rate_limit_check(session, state, config).await {
3925            if wait_ready {
3926                resp.extend_from_slice(&Self::create_ready_for_query(b'I'));
3927            }
3928            client
3929                .write_all(&resp)
3930                .await
3931                .map_err(|e| ProxyError::Network(format!("Client write error: {}", e)))?;
3932            return Ok((None, resp.len() as u64));
3933        }
3934
3935        // Analytics: the routable SQL (first Parse) + latency timer.
3936        #[cfg(feature = "query-analytics")]
3937        let analytics_sql = route_sql.map(|s| s.to_string());
3938        #[cfg(feature = "query-analytics")]
3939        let started = std::time::Instant::now();
3940
3941        let target = match route_sql {
3942            Some(sql) => {
3943                // Routing-hints, when active, can override the verb-based
3944                // target (and recompute the write flag on the stripped SQL).
3945                #[cfg(feature = "routing-hints")]
3946                let (is_write, forced) = Self::extended_hint_route(state, sql)
3947                    .unwrap_or_else(|| (Self::is_write_query(sql), None));
3948                #[cfg(not(feature = "routing-hints"))]
3949                let (is_write, forced): (bool, Option<String>) = (Self::is_write_query(sql), None);
3950                #[cfg(feature = "lag-routing")]
3951                if is_write && config.lag_routing.enabled {
3952                    *session.last_write_at.write().await = Some(std::time::Instant::now());
3953                }
3954                Self::choose_target_node(is_write, forced, current_node, session, state, config)
3955                    .await?
3956            }
3957            // No Parse in this batch: stay on the prepared-statement /
3958            // portal connection. Fall back to a read node only if the
3959            // session has no current connection yet.
3960            None => match current_node {
3961                Some(c) => c.to_string(),
3962                None => Self::select_read_node(session, state, config).await?,
3963            },
3964        };
3965
3966        // Circuit breaker: fast-fail when the chosen node's circuit is open.
3967        #[cfg(feature = "circuit-breaker")]
3968        if let Some(mut resp) = Self::circuit_fast_fail(state, &target) {
3969            if wait_ready {
3970                resp.extend_from_slice(&Self::create_ready_for_query(b'I'));
3971            }
3972            client
3973                .write_all(&resp)
3974                .await
3975                .map_err(|e| ProxyError::Network(format!("Client write error: {}", e)))?;
3976            return Ok((None, resp.len() as u64));
3977        }
3978
3979        if let Err(e) = Self::ensure_conn(conns, &target, session, config, state).await {
3980            Self::record_backend_failure(state, &target, &e.to_string());
3981            return Err(e);
3982        }
3983        let backend = conns.get_mut(&target).expect("just ensured");
3984
3985        // Transparently re-prepare any referenced named statement this socket
3986        // is missing. Each is sent as its original `Parse` + `Flush`; the
3987        // resulting `ParseComplete` is consumed here so the client never sees
3988        // the extra round trip. A re-prepare failure recycles the connection.
3989        for name in reprepare {
3990            if backend.prepared.contains(name) {
3991                continue;
3992            }
3993            let Some(parse_bytes) = registry.get(name) else {
3994                continue; // unknown statement — let the batch surface the error
3995            };
3996            match Self::reprepare_statement(&mut backend.stream, parse_bytes).await {
3997                Ok(()) => {
3998                    backend.prepared.insert(name.clone());
3999                }
4000                Err(e) => {
4001                    conns.remove(&target);
4002                    return Err(e);
4003                }
4004            }
4005        }
4006
4007        // Unnamed-`Parse` promotion: if the held unnamed Parse matches what this
4008        // connection's unnamed statement already holds, skip forwarding it and
4009        // synthesize its `ParseComplete` to the client; otherwise forward it
4010        // first (re-establishing the connection's unnamed statement) and record
4011        // its signature. A fresh/redialed connection has no signature, so the
4012        // Parse is always (re)forwarded there — correctness is preserved.
4013        let mut inject_parse_complete = false;
4014        let mut new_unnamed_sig: Option<bytes::Bytes> = None;
4015        if let Some((parse_msg, sig)) = unnamed.as_ref() {
4016            if backend.unnamed_sig.as_deref() == Some(&sig[..]) {
4017                inject_parse_complete = true;
4018            } else {
4019                if let Err(e) = backend
4020                    .stream
4021                    .write_all(parse_msg)
4022                    .await
4023                    .map_err(|e| ProxyError::Network(format!("Backend write error: {}", e)))
4024                {
4025                    conns.remove(&target);
4026                    return Err(e);
4027                }
4028                new_unnamed_sig = Some(sig.clone());
4029            }
4030        }
4031
4032        let batch_err = match tokio::time::timeout(
4033            Self::BACKEND_WRITE_TIMEOUT,
4034            backend.stream.write_all(batch),
4035        )
4036        .await
4037        {
4038            Ok(Ok(())) => None,
4039            Ok(Err(e)) => Some(format!("Backend write error: {}", e)),
4040            Err(_) => Some("Backend write timeout".to_string()),
4041        };
4042        if let Some(msg) = batch_err {
4043            let e = ProxyError::Network(msg);
4044            conns.remove(&target);
4045            Self::record_backend_failure(state, &target, &e.to_string());
4046            return Err(e);
4047        }
4048
4049        // The client expects `ParseComplete` first; the backend won't send one
4050        // for a skipped Parse, so emit it here before relaying the response.
4051        let mut injected: u64 = 0;
4052        if inject_parse_complete {
4053            if let Err(e) = client
4054                .write_all(&[b'1', 0, 0, 0, 4])
4055                .await
4056                .map_err(|e| ProxyError::Network(format!("Client write error: {}", e)))
4057            {
4058                conns.remove(&target);
4059                return Err(e);
4060            }
4061            injected = 5;
4062        }
4063
4064        let r = if wait_ready {
4065            Self::stream_until_ready(client, &mut backend.stream, session, state).await
4066        } else {
4067            Self::stream_flush(client, &mut backend.stream, session, state).await
4068        };
4069        match r {
4070            Ok(sent) => {
4071                #[cfg(feature = "circuit-breaker")]
4072                Self::circuit_record(state, &target, true, "");
4073                #[cfg(feature = "query-analytics")]
4074                if let Some(sql) = analytics_sql.as_deref() {
4075                    Self::record_analytics(state, session, sql, &target, started.elapsed(), None)
4076                        .await;
4077                }
4078                // The connection now holds these named statements.
4079                for name in defines {
4080                    backend.prepared.insert(name.clone());
4081                }
4082                // ...and the (re)forwarded unnamed statement.
4083                if let Some(sig) = new_unnamed_sig {
4084                    backend.unnamed_sig = Some(sig);
4085                }
4086                Ok((Some(target), sent + injected))
4087            }
4088            Err(e) => {
4089                conns.remove(&target);
4090                Self::record_backend_failure(state, &target, &e.to_string());
4091                #[cfg(feature = "query-analytics")]
4092                if let Some(sql) = analytics_sql.as_deref() {
4093                    Self::record_analytics(
4094                        state,
4095                        session,
4096                        sql,
4097                        &target,
4098                        started.elapsed(),
4099                        Some(e.to_string()),
4100                    )
4101                    .await;
4102                }
4103                Err(e)
4104            }
4105        }
4106    }
4107
4108    /// Re-issue one named `Parse` on a backend socket out-of-band: send the
4109    /// original `Parse` bytes followed by a `Flush`, then read and discard the
4110    /// single `ParseComplete` the backend emits. The statement persists on the
4111    /// connection (the implicit transaction is closed later by the real
4112    /// batch's `Sync`). An `ErrorResponse` means the re-prepare failed.
4113    async fn reprepare_statement<S: AsyncReadExt + AsyncWriteExt + Unpin>(
4114        backend: &mut S,
4115        parse_bytes: &[u8],
4116    ) -> Result<()> {
4117        tokio::time::timeout(Self::REPREPARE_TIMEOUT, backend.write_all(parse_bytes))
4118            .await
4119            .map_err(|_| ProxyError::Network("re-prepare write timeout".to_string()))?
4120            .map_err(|e| ProxyError::Network(format!("re-prepare write error: {}", e)))?;
4121        // Flush: 'H' + length 4.
4122        tokio::time::timeout(
4123            Self::REPREPARE_TIMEOUT,
4124            backend.write_all(&[b'H', 0, 0, 0, 4]),
4125        )
4126        .await
4127        .map_err(|_| ProxyError::Network("re-prepare flush timeout".to_string()))?
4128        .map_err(|e| ProxyError::Network(format!("re-prepare flush error: {}", e)))?;
4129        let mtype =
4130            tokio::time::timeout(Self::REPREPARE_TIMEOUT, Self::read_one_frame_type(backend))
4131                .await
4132                .map_err(|_| ProxyError::Network("re-prepare read timeout".to_string()))??;
4133        match mtype {
4134            b'1' => Ok(()), // ParseComplete
4135            b'E' => Err(ProxyError::Protocol(
4136                "re-prepare rejected by backend".to_string(),
4137            )),
4138            other => Err(ProxyError::Protocol(format!(
4139                "unexpected re-prepare reply: {}",
4140                other as char
4141            ))),
4142        }
4143    }
4144
4145    /// Read exactly one backend message frame (5-byte header + body) and return
4146    /// its type byte, discarding the body. Used to consume the `ParseComplete`
4147    /// produced by an out-of-band re-prepare.
4148    async fn read_one_frame_type<S: AsyncReadExt + Unpin>(backend: &mut S) -> Result<u8> {
4149        let mut header = [0u8; 5];
4150        backend
4151            .read_exact(&mut header)
4152            .await
4153            .map_err(|e| ProxyError::Network(format!("re-prepare read error: {}", e)))?;
4154        let len = u32::from_be_bytes([header[1], header[2], header[3], header[4]]) as usize;
4155        let body_len = len.saturating_sub(4);
4156        if body_len > 0 {
4157            let mut body = vec![0u8; body_len];
4158            backend
4159                .read_exact(&mut body)
4160                .await
4161                .map_err(|e| ProxyError::Network(format!("re-prepare body read error: {}", e)))?;
4162        }
4163        Ok(header[0])
4164    }
4165
4166    /// Name a `Parse` defines: its first cstring. `""` is the unnamed
4167    /// statement, which is per-protocol transient and never tracked.
4168    fn parse_stmt_name(payload: &[u8]) -> &str {
4169        let end = payload.iter().position(|&b| b == 0).unwrap_or(0);
4170        std::str::from_utf8(&payload[..end]).unwrap_or("")
4171    }
4172
4173    /// Prepared-statement name a `Bind` references: the *second* cstring
4174    /// (portal name first, then statement name). `None` for the unnamed
4175    /// statement.
4176    fn bind_stmt_ref(payload: &[u8]) -> Option<&str> {
4177        let portal_end = payload.iter().position(|&b| b == 0)?;
4178        let rest = &payload[portal_end + 1..];
4179        let stmt_end = rest.iter().position(|&b| b == 0)?;
4180        let name = std::str::from_utf8(&rest[..stmt_end]).ok()?;
4181        (!name.is_empty()).then_some(name)
4182    }
4183
4184    /// Statement name a `Describe`/`Close` targets — only when it is
4185    /// statement-kind (`'S'`, not portal `'P'`). `None` otherwise.
4186    fn stmt_kind_name(payload: &[u8]) -> Option<&str> {
4187        if payload.first() != Some(&b'S') {
4188            return None;
4189        }
4190        let rest = &payload[1..];
4191        let end = rest.iter().position(|&b| b == 0)?;
4192        let name = std::str::from_utf8(&rest[..end]).ok()?;
4193        (!name.is_empty()).then_some(name)
4194    }
4195
4196    /// Stream backend response frames to the client until ReadyForQuery (end
4197    /// of a Sync/simple-query response). Forwards bytes verbatim, coalescing
4198    /// all currently-complete frames into one write and keeping only a
4199    /// partial-frame tail buffered, so proxy memory stays O(frame) rather
4200    /// than O(result). Also yields on CopyInResponse/CopyBothResponse so the
4201    /// client can supply COPY data. Updates `tx_state` from the RFQ status.
4202    /// Returns bytes streamed to the client.
4203    async fn stream_until_ready(
4204        client: &mut ClientStream,
4205        backend: &mut TcpStream,
4206        session: &Arc<ClientSession>,
4207        state: &Arc<ServerState>,
4208    ) -> Result<u64> {
4209        let _ = state;
4210        let mut buf = BytesMut::with_capacity(16384);
4211        let mut sent: u64 = 0;
4212
4213        loop {
4214            // Walk complete frames in `buf`, stopping at a boundary frame.
4215            let mut consumed = 0usize;
4216            let mut ready_status: Option<u8> = None;
4217            let mut yield_for_copy = false;
4218            loop {
4219                let rem = &buf[consumed..];
4220                if rem.len() < 5 {
4221                    break;
4222                }
4223                let len = u32::from_be_bytes([rem[1], rem[2], rem[3], rem[4]]) as usize;
4224                if len < 4 || rem.len() < len + 1 {
4225                    break; // incomplete or malformed length — need more bytes
4226                }
4227                let frame_total = len + 1;
4228                let mtype = rem[0];
4229                consumed += frame_total;
4230                if mtype == b'Z' {
4231                    // ReadyForQuery: payload is one status byte at rem[5].
4232                    ready_status = Some(if frame_total >= 6 { rem[5] } else { b'I' });
4233                    break;
4234                }
4235                if mtype == b'G' || mtype == b'W' {
4236                    // CopyInResponse / CopyBothResponse: the backend now wants
4237                    // CopyData from the client — forward up to here and yield.
4238                    yield_for_copy = true;
4239                    break;
4240                }
4241            }
4242
4243            if consumed > 0 {
4244                tokio::time::timeout(
4245                    Self::CLIENT_WRITE_TIMEOUT,
4246                    client.write_all(&buf[..consumed]),
4247                )
4248                .await
4249                .map_err(|_| ProxyError::Network("Client write timeout".to_string()))?
4250                .map_err(|e| ProxyError::Network(format!("Client write error: {}", e)))?;
4251                sent += consumed as u64;
4252                let _ = buf.split_to(consumed);
4253            }
4254
4255            if let Some(status) = ready_status {
4256                let st = TransactionStatus::from_byte(status);
4257                session.in_transaction.store(
4258                    st != TransactionStatus::Idle,
4259                    std::sync::atomic::Ordering::Relaxed,
4260                );
4261                return Ok(sent);
4262            }
4263            if yield_for_copy {
4264                // The backend now awaits CopyData from the client; the session
4265                // is mid-COPY, not at a clean boundary. Mark it so pool release
4266                // is suppressed until the COPY drains (cleared in the CopyDone
4267                // path). Harmless in session mode (release is a no-op there).
4268                session
4269                    .copy_in_progress
4270                    .store(true, std::sync::atomic::Ordering::Relaxed);
4271                return Ok(sent);
4272            }
4273
4274            // Read straight into the frame accumulator — no zeroed scratch, no
4275            // copy. `read_buf` appends to `buf`'s spare capacity.
4276            buf.reserve(16384);
4277            let n = tokio::time::timeout(Duration::from_secs(30), backend.read_buf(&mut buf))
4278                .await
4279                .map_err(|_| ProxyError::Network("Backend read timeout".to_string()))?
4280                .map_err(|e| ProxyError::Network(format!("Backend read error: {}", e)))?;
4281            if n == 0 {
4282                return Err(ProxyError::Connection(
4283                    "Backend closed mid-response".to_string(),
4284                ));
4285            }
4286        }
4287    }
4288
4289    /// Like `stream_until_ready` but also captures the full response bytes for
4290    /// caching (query-cache and/or edge cache — one shared capture).
4291    /// Returns `(bytes_sent, captured, cacheable, row_count)`.
4292    /// `cacheable` is false if the response carried an `ErrorResponse`, ended in
4293    /// a non-idle transaction status, yielded for COPY, or contained an
4294    /// asynchronous backend frame — NotificationResponse ('A'), NoticeResponse
4295    /// ('N'), or ParameterStatus ('S'). Async frames belong to THIS session's
4296    /// moment in time (a LISTENing session's notification, a GUC change);
4297    /// replaying them to every later hitter would leak the notification
4298    /// payload cross-session and desynchronize hitters' parameter state. The
4299    /// frames are still forwarded to the live requester — only the store is
4300    /// suppressed.
4301    #[cfg(any(feature = "query-cache", feature = "edge-proxy"))]
4302    async fn stream_until_ready_capture(
4303        client: &mut ClientStream,
4304        backend: &mut TcpStream,
4305        session: &Arc<ClientSession>,
4306    ) -> Result<(u64, Vec<u8>, bool, usize)> {
4307        let mut buf = BytesMut::with_capacity(16384);
4308        let mut sent: u64 = 0;
4309        let mut captured: Vec<u8> = Vec::with_capacity(4096);
4310        let mut had_error = false;
4311        let mut saw_async = false;
4312        let mut row_count: usize = 0;
4313
4314        loop {
4315            let mut consumed = 0usize;
4316            let mut ready_status: Option<u8> = None;
4317            let mut yield_for_copy = false;
4318            loop {
4319                let rem = &buf[consumed..];
4320                if rem.len() < 5 {
4321                    break;
4322                }
4323                let len = u32::from_be_bytes([rem[1], rem[2], rem[3], rem[4]]) as usize;
4324                if len < 4 || rem.len() < len + 1 {
4325                    break;
4326                }
4327                let frame_total = len + 1;
4328                let mtype = rem[0];
4329                if mtype == b'E' {
4330                    had_error = true;
4331                }
4332                // Async backend frames (backend 'S' is unambiguous here —
4333                // PortalSuspended is lowercase 's').
4334                if mtype == b'A' || mtype == b'N' || mtype == b'S' {
4335                    saw_async = true;
4336                }
4337                if mtype == b'C' {
4338                    // CommandComplete tag, e.g. "SELECT 5" — take the row count.
4339                    if let Some(tag) = rem.get(5..frame_total) {
4340                        if let Some(end) = tag.iter().position(|&b| b == 0) {
4341                            if let Ok(s) = std::str::from_utf8(&tag[..end]) {
4342                                if let Some(n) =
4343                                    s.rsplit(' ').next().and_then(|x| x.parse::<usize>().ok())
4344                                {
4345                                    row_count = n;
4346                                }
4347                            }
4348                        }
4349                    }
4350                }
4351                consumed += frame_total;
4352                if mtype == b'Z' {
4353                    ready_status = Some(if frame_total >= 6 { rem[5] } else { b'I' });
4354                    break;
4355                }
4356                if mtype == b'G' || mtype == b'W' {
4357                    yield_for_copy = true;
4358                    break;
4359                }
4360            }
4361
4362            if consumed > 0 {
4363                tokio::time::timeout(
4364                    Self::CLIENT_WRITE_TIMEOUT,
4365                    client.write_all(&buf[..consumed]),
4366                )
4367                .await
4368                .map_err(|_| ProxyError::Network("Client write timeout".to_string()))?
4369                .map_err(|e| ProxyError::Network(format!("Client write error: {}", e)))?;
4370                captured.extend_from_slice(&buf[..consumed]);
4371                sent += consumed as u64;
4372                let _ = buf.split_to(consumed);
4373            }
4374
4375            if let Some(status) = ready_status {
4376                let st = TransactionStatus::from_byte(status);
4377                session.in_transaction.store(
4378                    st != TransactionStatus::Idle,
4379                    std::sync::atomic::Ordering::Relaxed,
4380                );
4381                let cacheable = !had_error && status == b'I' && !saw_async;
4382                return Ok((sent, captured, cacheable, row_count));
4383            }
4384            if yield_for_copy {
4385                session
4386                    .copy_in_progress
4387                    .store(true, std::sync::atomic::Ordering::Relaxed);
4388                return Ok((sent, captured, false, row_count));
4389            }
4390
4391            // Read straight into the frame accumulator — no zeroed scratch.
4392            buf.reserve(16384);
4393            let n = tokio::time::timeout(Duration::from_secs(30), backend.read_buf(&mut buf))
4394                .await
4395                .map_err(|_| ProxyError::Network("Backend read timeout".to_string()))?
4396                .map_err(|e| ProxyError::Network(format!("Backend read error: {}", e)))?;
4397            if n == 0 {
4398                return Err(ProxyError::Connection(
4399                    "Backend closed mid-response".to_string(),
4400                ));
4401            }
4402        }
4403    }
4404
4405    /// Relay whatever the backend has *already* produced in response to a
4406    /// `Flush` (which, unlike `Sync`, yields no ReadyForQuery), then return
4407    /// immediately — without waiting.
4408    ///
4409    /// Any Flush output that has not landed in the socket yet is delivered by
4410    /// the main loop's backend watch (which relays the current backend's
4411    /// out-of-band bytes while waiting for the client), so there is no fixed
4412    /// post-Flush stall: the previous version blocked the session loop for up to
4413    /// 200 ms after the last backend byte before it would read the client's next
4414    /// message, adding that latency to every `Parse`/`Flush`-then-`Bind` prepare
4415    /// cycle. Here we drain what is instantly available and hand control back;
4416    /// the client's next frames are read at once. The eventual `Sync` drains the
4417    /// final ReadyForQuery via `stream_until_ready`.
4418    async fn stream_flush(
4419        client: &mut ClientStream,
4420        backend: &mut TcpStream,
4421        session: &Arc<ClientSession>,
4422        state: &Arc<ServerState>,
4423    ) -> Result<u64> {
4424        let _ = (session, state);
4425        let mut read_buf = vec![0u8; 16384];
4426        let mut sent: u64 = 0;
4427        loop {
4428            match backend.try_read(&mut read_buf) {
4429                Ok(0) => {
4430                    return Err(ProxyError::Connection(
4431                        "Backend closed mid-flush".to_string(),
4432                    ))
4433                }
4434                Ok(n) => {
4435                    client
4436                        .write_all(&read_buf[..n])
4437                        .await
4438                        .map_err(|e| ProxyError::Network(format!("Client write error: {}", e)))?;
4439                    sent += n as u64;
4440                }
4441                // Nothing more instantly available — the backend watch delivers
4442                // any remaining Flush output as it arrives.
4443                Err(e) if e.kind() == std::io::ErrorKind::WouldBlock => return Ok(sent),
4444                Err(e) => return Err(ProxyError::Network(format!("Backend read error: {}", e))),
4445            }
4446        }
4447    }
4448
4449    /// Check if a message is a write operation
4450    fn is_write_message(msg: &Message) -> bool {
4451        match msg.msg_type {
4452            MessageType::Query => {
4453                // Borrow the SQL straight out of the payload — the
4454                // message is forwarded verbatim, so no copy is needed
4455                // just to inspect the leading keyword.
4456                crate::protocol::query_text(&msg.payload)
4457                    .map(Self::is_write_query)
4458                    .unwrap_or(false)
4459            }
4460            MessageType::Parse => {
4461                // Parse payload = statement-name cstring + query
4462                // cstring; skip the name and borrow the query.
4463                msg.payload
4464                    .iter()
4465                    .position(|&b| b == 0)
4466                    .and_then(|end| crate::protocol::query_text(&msg.payload[end + 1..]))
4467                    .map(Self::is_write_query)
4468                    .unwrap_or(false)
4469            }
4470            // Execute, Bind, etc. maintain the current connection
4471            _ => false,
4472        }
4473    }
4474
4475    /// Check if SQL query is a write operation
4476    fn is_write_query(sql: &str) -> bool {
4477        use crate::protocol::starts_with_ci;
4478        let trimmed = sql.trim();
4479
4480        // Write operations
4481        if starts_with_ci(trimmed, "INSERT")
4482            || starts_with_ci(trimmed, "UPDATE")
4483            || starts_with_ci(trimmed, "DELETE")
4484            || starts_with_ci(trimmed, "CREATE")
4485            || starts_with_ci(trimmed, "DROP")
4486            || starts_with_ci(trimmed, "ALTER")
4487            || starts_with_ci(trimmed, "TRUNCATE")
4488            || starts_with_ci(trimmed, "GRANT")
4489            || starts_with_ci(trimmed, "REVOKE")
4490            || starts_with_ci(trimmed, "VACUUM")
4491            || starts_with_ci(trimmed, "REINDEX")
4492            || starts_with_ci(trimmed, "CLUSTER")
4493        {
4494            return true;
4495        }
4496
4497        // Transaction control goes to current node
4498        if starts_with_ci(trimmed, "BEGIN")
4499            || starts_with_ci(trimmed, "START")
4500            || starts_with_ci(trimmed, "COMMIT")
4501            || starts_with_ci(trimmed, "ROLLBACK")
4502            || starts_with_ci(trimmed, "SAVEPOINT")
4503            || starts_with_ci(trimmed, "RELEASE")
4504        {
4505            return true;
4506        }
4507
4508        // SET commands go to primary to maintain session state
4509        if starts_with_ci(trimmed, "SET") && !starts_with_ci(trimmed, "SET TRANSACTION READ ONLY") {
4510            return true;
4511        }
4512
4513        false
4514    }
4515
4516    /// Should this successfully-executed simple-query string trigger an edge
4517    /// invalidation?
4518    ///
4519    /// - Bare transaction control (BEGIN/START/SAVEPOINT/RELEASE/ROLLBACK)
4520    ///   changes no rows: exempt, or every ORM transaction would full-flush
4521    ///   the whole fleet twice per request. COMMIT is deliberately NOT
4522    ///   exempt (its flush closes the invalidate-at-statement vs
4523    ///   commit-visibility window for in-transaction writes), nor is SET
4524    ///   (the flush is the interim mitigation for GUC-sensitive results).
4525    /// - A multi-statement string (interior `;`) may hide a trailing write
4526    ///   behind a read-classified lead — always invalidate (the fingerprint
4527    ///   extracts tables from every sub-statement; over-invalidation only).
4528    /// - `COPY ... FROM` loads rows but is not classified `is_write` (it
4529    ///   must not be re-routed); catch it here for invalidation.
4530    #[cfg(feature = "edge-proxy")]
4531    fn edge_write_needs_invalidation(is_write: bool, sql: &str) -> bool {
4532        use crate::protocol::starts_with_ci;
4533        let t = sql.trim();
4534        let core = t.strip_suffix(';').unwrap_or(t).trim_end();
4535        let multi_stmt = core.contains(';');
4536        if !multi_stmt
4537            && (starts_with_ci(core, "BEGIN")
4538                || starts_with_ci(core, "START")
4539                || starts_with_ci(core, "SAVEPOINT")
4540                || starts_with_ci(core, "RELEASE")
4541                || starts_with_ci(core, "ROLLBACK"))
4542        {
4543            return false;
4544        }
4545        is_write
4546            || multi_stmt
4547            || Self::is_edge_copy_write_sql(core)
4548            || Self::is_edge_procedural_sql(core)
4549            || Self::is_edge_txn_end_sql(core)
4550    }
4551
4552    /// `COPY ... FROM ...` (STDIN or file) loads rows. Word-boundary FROM so
4553    /// `COPY t TO ...` stays a read; a `COPY (SELECT ... FROM t) TO ...`
4554    /// false-positive only over-invalidates — safe.
4555    #[cfg(feature = "edge-proxy")]
4556    fn is_edge_copy_write_sql(sql: &str) -> bool {
4557        crate::protocol::starts_with_ci(sql.trim_start(), "COPY")
4558            && Self::contains_word_ci(sql, "from")
4559    }
4560
4561    /// Which of a batch's Closed statement names may have their edge
4562    /// invalidation metadata pruned at a Sync boundary. A name Closed and then
4563    /// re-Parsed in the SAME batch must keep its FRESH metadata (dropping it
4564    /// would silently disable invalidation for the live re-prepared statement —
4565    /// the Npgsql statement-replacement pattern), so it is excluded. Pruning is
4566    /// additionally gated on the Sync by the caller, so a Close seen at an
4567    /// earlier Flush keeps its metadata alive for the terminating Sync's
4568    /// invalidation hook. Regression guard for the G1 finding.
4569    #[cfg(feature = "edge-proxy")]
4570    fn edge_meta_prunable<'a>(closes: &'a [String], defines: &[String]) -> Vec<&'a str> {
4571        closes
4572            .iter()
4573            .filter(|n| !defines.contains(n))
4574            .map(String::as_str)
4575            .collect()
4576    }
4577
4578    /// SQL-level statements whose written table set cannot be attributed
4579    /// statically — `EXECUTE` (runs a prepared plan), `CALL` (a procedure),
4580    /// `DO` (an anonymous block, often dynamic SQL). Treated as an
4581    /// invalidate-everything wildcard write. Rare on the wire (no mainstream
4582    /// driver emits them for data changes), so the full flush is cheap in
4583    /// practice and over-invalidation is always safe.
4584    #[cfg(feature = "edge-proxy")]
4585    fn is_edge_procedural_sql(sql: &str) -> bool {
4586        use crate::protocol::starts_with_ci;
4587        let t = sql.trim_start();
4588        starts_with_ci(t, "EXECUTE") || starts_with_ci(t, "CALL") || starts_with_ci(t, "DO")
4589    }
4590
4591    /// Transaction-ending statements: `COMMIT`/`END` and their variants
4592    /// (`COMMIT WORK`/`AND CHAIN`/`PREPARED`, `END TRANSACTION`). They make a
4593    /// transaction's in-flight writes visible, so — like the simple-path
4594    /// COMMIT — they trigger the conservative wildcard flush that closes the
4595    /// invalidate-at-statement vs commit-visibility window. `BEGIN`/`START`/
4596    /// `SAVEPOINT`/`RELEASE`/`ROLLBACK` are excluded (they make nothing newly
4597    /// visible). `END` is a COMMIT synonym here; a rare non-transaction
4598    /// statement that happens to start with `END` only over-invalidates.
4599    #[cfg(feature = "edge-proxy")]
4600    fn is_edge_txn_end_sql(sql: &str) -> bool {
4601        use crate::protocol::starts_with_ci;
4602        let t = sql.trim_start();
4603        starts_with_ci(t, "COMMIT") || starts_with_ci(t, "END")
4604    }
4605
4606    /// Extended-protocol statement classifier for edge invalidation: does
4607    /// this Parse'd SQL modify table data when executed? Deliberately NOT
4608    /// `is_write_query` — that routing classifier counts BEGIN/COMMIT/SET as
4609    /// writes, and their empty table set would full-flush the fleet on every
4610    /// transaction commit. WITH-prefixed statements are checked for
4611    /// data-modifying CTE verbs by word (false positives over-invalidate
4612    /// only).
4613    #[cfg(feature = "edge-proxy")]
4614    fn is_edge_dml_sql(sql: &str) -> bool {
4615        use crate::protocol::starts_with_ci;
4616        let t = sql.trim_start();
4617        if starts_with_ci(t, "INSERT")
4618            || starts_with_ci(t, "UPDATE")
4619            || starts_with_ci(t, "DELETE")
4620            || starts_with_ci(t, "MERGE")
4621            || starts_with_ci(t, "CREATE")
4622            || starts_with_ci(t, "DROP")
4623            || starts_with_ci(t, "ALTER")
4624            || starts_with_ci(t, "TRUNCATE")
4625            || starts_with_ci(t, "GRANT")
4626            || starts_with_ci(t, "REVOKE")
4627        {
4628            return true;
4629        }
4630        if starts_with_ci(t, "COPY") {
4631            return Self::contains_word_ci(t, "from");
4632        }
4633        if starts_with_ci(t, "WITH") {
4634            return Self::contains_word_ci(t, "insert")
4635                || Self::contains_word_ci(t, "update")
4636                || Self::contains_word_ci(t, "delete")
4637                || Self::contains_word_ci(t, "merge");
4638        }
4639        false
4640    }
4641
4642    /// Union the invalidation table set for an extended-protocol batch from
4643    /// the per-statement metadata memoized at Parse time. Returns `None`
4644    /// when the batch references no DML statement; `Some(vec![])` (the
4645    /// invalidate-everything wildcard) when any referenced DML has an
4646    /// unattributable table set.
4647    #[cfg(feature = "edge-proxy")]
4648    fn edge_extended_batch_tables(
4649        refs: &[String],
4650        bound_unnamed: bool,
4651        named_meta: &HashMap<String, Option<Vec<String>>>,
4652        unnamed_meta: &Option<Vec<String>>,
4653    ) -> Option<Vec<String>> {
4654        let mut any_dml = false;
4655        let mut wipe_all = false;
4656        let mut union: Vec<String> = Vec::new();
4657        {
4658            let mut consider = |meta: &Option<Vec<String>>| {
4659                if let Some(tables) = meta {
4660                    any_dml = true;
4661                    if tables.is_empty() {
4662                        wipe_all = true;
4663                    } else {
4664                        for t in tables {
4665                            if !union.contains(t) {
4666                                union.push(t.clone());
4667                            }
4668                        }
4669                    }
4670                }
4671            };
4672            for name in refs {
4673                if let Some(meta) = named_meta.get(name) {
4674                    consider(meta);
4675                }
4676            }
4677            if bound_unnamed {
4678                consider(unnamed_meta);
4679            }
4680        }
4681        if !any_dml {
4682            None
4683        } else if wipe_all {
4684            Some(Vec::new())
4685        } else {
4686            Some(union)
4687        }
4688    }
4689
4690    /// Version-stamp a completed write, drop matching local entries, and
4691    /// (home role) fan the invalidation out over SSE. An edge never
4692    /// broadcasts — the home versions writes, so the edge sweeps in the
4693    /// observed-home domain and lets the home's own event follow.
4694    #[cfg(feature = "edge-proxy")]
4695    async fn edge_invalidate_write(
4696        state: &Arc<ServerState>,
4697        config: &ProxyConfig,
4698        tables: Vec<String>,
4699    ) {
4700        if config.edge.role == crate::edge::EdgeRole::Home {
4701            let version = state.edge_cache.next_version();
4702            let dropped = state.edge_cache.invalidate(version, &tables);
4703            let (notified, pruned) = state
4704                .edge_registry
4705                .broadcast(crate::edge::InvalidationEvent {
4706                    up_to_version: version,
4707                    tables,
4708                    committed_at: chrono::Utc::now().to_rfc3339(),
4709                    epoch: state.edge_cache.epoch(),
4710                })
4711                .await;
4712            tracing::debug!(
4713                target: "helios::edge",
4714                version,
4715                dropped,
4716                notified,
4717                pruned,
4718                "write invalidation broadcast to edges"
4719            );
4720        } else {
4721            // Edge-local sweep in the observed-home domain: every locally
4722            // cached entry is stamped at or below the observed version, so
4723            // this drops all entries for the touched tables. It also bumps
4724            // the invalidation epoch, rejecting in-flight read stores that
4725            // raced this write.
4726            let version = state.edge_cache.observed_home_version();
4727            let dropped = state.edge_cache.invalidate(version, &tables);
4728            tracing::debug!(
4729                target: "helios::edge",
4730                version,
4731                dropped,
4732                "write invalidated local edge cache (edge role — home broadcasts)"
4733            );
4734        }
4735    }
4736
4737    /// Conservative classifier for the conditional-reset optimisation: could
4738    /// this forwarded simple-query SQL leave *session-level* state on the
4739    /// backend connection that `DISCARD ALL` would need to clear before another
4740    /// client reuses it (a `SET`/GUC, temp table, prepared statement, cursor
4741    /// WITH HOLD, `LISTEN`, advisory lock, session authorization, …)?
4742    ///
4743    /// Biased hard toward `true`. A false negative (calling a dirtying
4744    /// statement clean) would leak state to the next borrower — a correctness
4745    /// and security bug — so only statements *provably* session-neutral return
4746    /// `false`; everything ambiguous returns `true` (forcing the full reset,
4747    /// which is merely slower, never unsafe).
4748    ///
4749    /// Known, documented limitation: a `SELECT` that calls a user-defined
4750    /// function which internally runs `set_config(..., is_local => false)` or
4751    /// takes an advisory lock via an aliased path is NOT detectable from the
4752    /// SQL text. The direct forms (`set_config`, `pg_advisory*`, `nextval`,
4753    /// `setval`) ARE caught. This is why `skip_clean_reset` is opt-in and
4754    /// intended for autocommit/simple-protocol workloads.
4755    ///
4756    /// Also reused by the edge cache as its sticky session-eligibility
4757    /// gate: a session that leaves session state (GUCs, SET ROLE, temp
4758    /// objects) no longer matches the shared cache's key model and is
4759    /// permanently excluded from edge lookup/store.
4760    #[cfg(any(feature = "pool-modes", feature = "edge-proxy"))]
4761    fn stmt_leaves_session_state(sql: &str) -> bool {
4762        use crate::protocol::{contains_ci, starts_with_ci};
4763        let t = sql.trim();
4764        if t.is_empty() {
4765            return false;
4766        }
4767        // Multiple statements in one simple-query string: a leading-keyword
4768        // check cannot vouch for what follows a `;`, so treat any non-trailing
4769        // `;` as dirtying. A `;` inside a string literal also trips this —
4770        // safe, merely an unnecessary reset.
4771        let core = t.strip_suffix(';').unwrap_or(t).trim_end();
4772        if core.contains(';') {
4773            return true;
4774        }
4775        // The statement's leading keyword must be one that provably leaves no
4776        // session state. CREATE / SET / PREPARE / DECLARE / LISTEN / DISCARD /
4777        // RESET / GRANT / ALTER / LOCK / COPY / … are all absent here, so they
4778        // fall through to `true` (dirtying).
4779        let neutral_lead = starts_with_ci(core, "SELECT")
4780            || starts_with_ci(core, "INSERT")
4781            || starts_with_ci(core, "UPDATE")
4782            || starts_with_ci(core, "DELETE")
4783            || starts_with_ci(core, "WITH")
4784            || starts_with_ci(core, "VALUES")
4785            || starts_with_ci(core, "TABLE")
4786            || starts_with_ci(core, "SHOW")
4787            || starts_with_ci(core, "EXPLAIN")
4788            || starts_with_ci(core, "FETCH")
4789            || starts_with_ci(core, "BEGIN")
4790            || starts_with_ci(core, "START")
4791            || starts_with_ci(core, "COMMIT")
4792            || starts_with_ci(core, "END")
4793            || starts_with_ci(core, "ROLLBACK")
4794            || starts_with_ci(core, "ABORT")
4795            || starts_with_ci(core, "SAVEPOINT")
4796            || starts_with_ci(core, "RELEASE");
4797        if !neutral_lead {
4798            return true;
4799        }
4800        // A neutral-lead statement can still create session state:
4801        //  * `SELECT ... INTO [TEMP] t` (and the `WITH … SELECT … INTO` form)
4802        //    creates a table. The `INTO` keyword is matched as a whole word (so
4803        //    a column name like `into_total` does not trip it) and ONLY for
4804        //    SELECT/WITH leads — `INSERT INTO`, `UPDATE`, `DELETE` use `INTO`
4805        //    (or not) as ordinary syntax and leave no session state.
4806        //  * `set_config()` sets a GUC; `pg_advisory*` takes a session lock;
4807        //    `nextval`/`setval` touch the per-session sequence cache.
4808        if (starts_with_ci(core, "SELECT") || starts_with_ci(core, "WITH"))
4809            && Self::contains_word_ci(core, "into")
4810        {
4811            return true;
4812        }
4813        const DIRTY_TOKENS: [&str; 4] = ["set_config", "advisory", "nextval", "setval"];
4814        DIRTY_TOKENS.iter().any(|tok| contains_ci(core, tok))
4815    }
4816
4817    /// Case-insensitive whole-word (ASCII identifier-boundary) search — a match
4818    /// requires the token to be bounded by a non-`[A-Za-z0-9_]` char (or the
4819    /// string edge) on both sides, so a real SQL keyword like `INTO` is caught
4820    /// regardless of surrounding whitespace while an identifier substring
4821    /// (`into_total`) is not.
4822    #[cfg(any(
4823        feature = "pool-modes",
4824        feature = "edge-proxy",
4825        feature = "query-cache"
4826    ))]
4827    fn contains_word_ci(haystack: &str, word: &str) -> bool {
4828        let hb = haystack.as_bytes();
4829        let wb = word.as_bytes();
4830        if wb.is_empty() || hb.len() < wb.len() {
4831            return false;
4832        }
4833        let is_ident = |c: u8| c.is_ascii_alphanumeric() || c == b'_';
4834        let mut i = 0;
4835        while i + wb.len() <= hb.len() {
4836            if hb[i..i + wb.len()].eq_ignore_ascii_case(wb) {
4837                let before_ok = i == 0 || !is_ident(hb[i - 1]);
4838                let after = i + wb.len();
4839                let after_ok = after == hb.len() || !is_ident(hb[after]);
4840                if before_ok && after_ok {
4841                    return true;
4842                }
4843            }
4844            i += 1;
4845        }
4846        false
4847    }
4848
4849    /// Derive the rate-limit bucket key for a session per the configured
4850    /// keying dimension.
4851    #[cfg(feature = "rate-limiting")]
4852    async fn rate_limit_key(
4853        session: &Arc<ClientSession>,
4854        config: &ProxyConfig,
4855    ) -> crate::rate_limit::LimiterKey {
4856        use crate::config::RateLimitKeyBy;
4857        use crate::rate_limit::LimiterKey;
4858        match config.rate_limit.key_by {
4859            RateLimitKeyBy::Global => LimiterKey::Global,
4860            RateLimitKeyBy::ClientIp => LimiterKey::ClientIp(session.client_addr.ip()),
4861            RateLimitKeyBy::Database => {
4862                let vars = session.variables.read().await;
4863                LimiterKey::Database(vars.get("database").cloned().unwrap_or_default())
4864            }
4865            RateLimitKeyBy::User => {
4866                let vars = session.variables.read().await;
4867                LimiterKey::User(vars.get("user").cloned().unwrap_or_default())
4868            }
4869        }
4870    }
4871
4872    /// Check rate limits before a query is forwarded. Returns `Some(bytes)` —
4873    /// a PG `ErrorResponse` WITHOUT a trailing `ReadyForQuery` (the caller
4874    /// appends one as the protocol requires) — when the query is denied; `None`
4875    /// when it may proceed. A throttle/queue verdict is honored by sleeping for
4876    /// the engine-supplied delay (real backpressure, capped) and then allowing.
4877    #[cfg(feature = "rate-limiting")]
4878    async fn rate_limit_check(
4879        session: &Arc<ClientSession>,
4880        state: &Arc<ServerState>,
4881        config: &ProxyConfig,
4882    ) -> Option<Vec<u8>> {
4883        use crate::rate_limit::RateLimitResult;
4884        let limiter = state.rate_limiter.as_ref()?;
4885        let key = Self::rate_limit_key(session, config).await;
4886        match limiter.check(&key, 1) {
4887            RateLimitResult::Allowed => None,
4888            RateLimitResult::Warned(msg) => {
4889                tracing::warn!(key = %key, reason = %msg, "rate limit warning");
4890                None
4891            }
4892            RateLimitResult::Throttled(d) | RateLimitResult::Queued(d) => {
4893                // Cap the backpressure sleep so a misconfiguration can't pin a
4894                // connection task indefinitely.
4895                tokio::time::sleep(d.min(Duration::from_secs(5))).await;
4896                None
4897            }
4898            RateLimitResult::Denied(exc) => {
4899                tracing::info!(key = %key, "rate limit exceeded");
4900                let msg = format!(
4901                    "rate limit exceeded: {} (retry after {}ms)",
4902                    exc.message,
4903                    exc.retry_after.as_millis()
4904                );
4905                Some(Self::create_error_response("53400", &msg))
4906            }
4907        }
4908    }
4909
4910    /// In-band failure feedback. When a query fails against a backend, demote
4911    /// that node's health *immediately* — a copy-on-write update of the shared
4912    /// health snapshot, the same structure the periodic health checker
4913    /// maintains — so routing stops sending work to a dead node within one
4914    /// query instead of waiting up to a full health-check interval (the
4915    /// ~`check_interval` blind window). The periodic checker restores the node
4916    /// on its next successful probe, so this only ever *accelerates* detection.
4917    ///
4918    /// True when `err` is evidence the backend itself is unhealthy — and so
4919    /// should demote it in-band (and trip its circuit breaker) — as opposed to a
4920    /// client-side problem or a merely slow but healthy query.
4921    ///
4922    /// Excluded (return `false`, no penalty):
4923    /// * `Client …` — a failed or timed-out client write is the client's fault.
4924    /// * `Backend read timeout` — a backend that emits no bytes within the
4925    ///   streaming read window is indistinguishable from a legitimately slow but
4926    ///   healthy query (large sort/aggregate, lock wait, bulk DML). Demoting the
4927    ///   whole node — cluster-wide, for every session, bypassing the configured
4928    ///   `failure_threshold` — over one slow query is a false positive; a
4929    ///   genuinely unresponsive-but-connected backend is still caught by the
4930    ///   periodic protocol-level health probe.
4931    ///
4932    /// Still faults (return `true`): a backend read/write *error* (reset, EOF,
4933    /// broken pipe), a backend *write* timeout (the backend is not draining its
4934    /// socket), and any connect-time failure.
4935    fn is_backend_fault(err: &str) -> bool {
4936        !err.contains("Client") && !err.contains("Backend read timeout")
4937    }
4938
4939    /// Errors that do not demote a backend are filtered via `is_backend_fault`:
4940    /// a client disconnecting mid-query, or one merely-slow query, must never
4941    /// take a healthy backend out of rotation for every session.
4942    fn note_backend_failure(state: &Arc<ServerState>, addr: &str, err: &str) {
4943        if !Self::is_backend_fault(err) {
4944            return;
4945        }
4946        // Serialize the read-modify-write of the shared health snapshot. ArcSwap
4947        // makes only the final pointer swap atomic; without this lock two
4948        // concurrent writers — in-band demotions for different nodes, or an
4949        // in-band demotion racing the periodic checker's full-map rebuild — can
4950        // each load the same snapshot and clobber the other's update (a lost
4951        // update that resurrects a demoted node, or evicts a recovered one,
4952        // until the next probe). The lock serializes writers only; every routing
4953        // read stays lock-free on the ArcSwap.
4954        let _writers = state.health_write.lock();
4955        let snapshot = state.health.load_full();
4956        // Only act (and pay the clone) when the node is currently marked
4957        // healthy — avoids churning the snapshot on an already-down node.
4958        if snapshot.get(addr).map(|h| h.healthy).unwrap_or(false) {
4959            let mut next = (*snapshot).clone();
4960            if let Some(nh) = next.get_mut(addr) {
4961                nh.healthy = false;
4962                nh.failure_count = nh.failure_count.saturating_add(1);
4963                nh.last_error = Some(format!("in-band failure: {}", err));
4964                tracing::warn!(
4965                    node = %addr,
4966                    error = %err,
4967                    "in-band failure — node marked unhealthy for fast failover"
4968                );
4969            }
4970            state.health.store(Arc::new(next));
4971        }
4972    }
4973
4974    /// Record a backend forward failure: demote the node's health in-band AND
4975    /// (when the feature is on) trip its circuit breaker — the single place the
4976    /// data path reports "this backend just failed". Both signals consult the
4977    /// same `is_backend_fault` classifier, so they can never drift apart: a
4978    /// client-side error or a slow-query read timeout penalizes neither.
4979    fn record_backend_failure(state: &Arc<ServerState>, node: &str, err: &str) {
4980        Self::note_backend_failure(state, node, err);
4981        #[cfg(feature = "circuit-breaker")]
4982        if Self::is_backend_fault(err) {
4983            Self::circuit_record(state, node, false, err);
4984        }
4985    }
4986
4987    /// True when `node`'s circuit is open (avoid it / fast-fail). A half-open
4988    /// circuit returns false so a probe query is admitted.
4989    #[cfg(feature = "circuit-breaker")]
4990    fn circuit_is_open(state: &Arc<ServerState>, node: &str) -> bool {
4991        state
4992            .circuit_breaker
4993            .as_ref()
4994            .map(|cb| {
4995                cb.get_breaker(node).get_state() == crate::circuit_breaker::CircuitState::Open
4996            })
4997            .unwrap_or(false)
4998    }
4999
5000    /// Record the outcome of a forward to `node` on its circuit breaker.
5001    #[cfg(feature = "circuit-breaker")]
5002    fn circuit_record(state: &Arc<ServerState>, node: &str, success: bool, err: &str) {
5003        if let Some(cb) = state.circuit_breaker.as_ref() {
5004            let breaker = cb.get_breaker(node);
5005            if success {
5006                breaker.record_success();
5007            } else {
5008                breaker.record_failure(err);
5009            }
5010        }
5011    }
5012
5013    /// If `node`'s circuit is open, build the fast-fail `ErrorResponse` (without
5014    /// a trailing `ReadyForQuery` — the caller appends one). `None` when the
5015    /// circuit is closed or half-open and the request may proceed.
5016    #[cfg(feature = "circuit-breaker")]
5017    fn circuit_fast_fail(state: &Arc<ServerState>, node: &str) -> Option<Vec<u8>> {
5018        if Self::circuit_is_open(state, node) {
5019            tracing::info!(node = %node, "circuit open — fast-failing");
5020            Some(Self::create_error_response(
5021                "08006",
5022                &format!("circuit open for node {node}: backend temporarily unavailable"),
5023            ))
5024        } else {
5025            None
5026        }
5027    }
5028
5029    /// Read-your-writes decision: should reads be pinned to the primary given
5030    /// the session's last write and the configured window? Pure for testing.
5031    #[cfg(feature = "lag-routing")]
5032    fn ryw_pins_primary(last_write: Option<std::time::Instant>, window_ms: u64) -> bool {
5033        window_ms > 0
5034            && last_write
5035                .map(|t| t.elapsed() < Duration::from_millis(window_ms))
5036                .unwrap_or(false)
5037    }
5038
5039    /// Lag-exclusion decision: should a standby be dropped from read routing
5040    /// given its measured lag and the configured ceiling? `max=0` disables
5041    /// exclusion; unknown lag (None) never excludes. Pure for testing.
5042    #[cfg(feature = "lag-routing")]
5043    fn lag_excludes_standby(lag_bytes: Option<u64>, max_lag_bytes: u64) -> bool {
5044        max_lag_bytes > 0 && lag_bytes.map(|l| l > max_lag_bytes).unwrap_or(false)
5045    }
5046
5047    /// Pure predicate: is `sql` a plain, deterministic, SINGLE-statement
5048    /// SELECT safe to cache? (Not WITH/locking/volatile/multi-statement/
5049    /// SELECT INTO.) Transaction state is checked separately. Shared by the
5050    /// query-cache and edge-cache read gates.
5051    #[cfg(any(feature = "query-cache", feature = "edge-proxy"))]
5052    fn is_cacheable_read_sql(sql: &str) -> bool {
5053        use crate::protocol::{contains_ci, starts_with_ci};
5054        let t = sql.trim_start();
5055        if !starts_with_ci(t, "SELECT") {
5056            return false;
5057        }
5058        // Multi-statement simple-query strings (`SELECT ...; UPDATE ...`)
5059        // must never be cached: replaying the capture would fabricate the
5060        // trailing statements' results while executing nothing. One
5061        // trailing `;` is fine; a `;` inside a string literal only costs
5062        // cacheability — safe.
5063        let core = t.trim_end();
5064        let core = core.strip_suffix(';').map(str::trim_end).unwrap_or(core);
5065        if core.contains(';') {
5066            return false;
5067        }
5068        // `SELECT ... INTO t` CREATES a table (CREATE TABLE AS synonym);
5069        // replaying it from cache would silently skip the DDL. Word-boundary
5070        // match, so newline/tab-delimited INTO is caught while a column
5071        // named `into_x` is not (a literal containing the word merely skips
5072        // caching).
5073        if Self::contains_word_ci(core, "into") {
5074            return false;
5075        }
5076        if contains_ci(t, "FOR UPDATE") || contains_ci(t, "FOR SHARE") {
5077            return false;
5078        }
5079        // Non-deterministic or side-effectful reads must not be reused
5080        // (set_config emits ParameterStatus and mutates GUCs — replaying it
5081        // would suppress the side effect).
5082        const VOLATILE: [&str; 11] = [
5083            "now(",
5084            "current_timestamp",
5085            "current_date",
5086            "current_time",
5087            "clock_timestamp",
5088            "statement_timestamp",
5089            "random(",
5090            "nextval(",
5091            "uuid_generate",
5092            "gen_random_uuid",
5093            "set_config(",
5094        ];
5095        !VOLATILE.iter().any(|v| contains_ci(t, v))
5096    }
5097
5098    /// Decide whether a read query is safe to serve from / store in the cache,
5099    /// and build its `CacheContext`. Returns `None` for anything not a plain,
5100    /// deterministic, non-transactional SELECT.
5101    #[cfg(feature = "query-cache")]
5102    async fn cacheable_read_ctx(
5103        session: &Arc<ClientSession>,
5104        sql: &str,
5105    ) -> Option<crate::cache::CacheContext> {
5106        if !Self::is_cacheable_read_sql(sql) {
5107            return None;
5108        }
5109        // Never cache mid-transaction (visibility would be wrong).
5110        if session
5111            .in_transaction
5112            .load(std::sync::atomic::Ordering::Relaxed)
5113        {
5114            return None;
5115        }
5116        let (user, database) = {
5117            let vars = session.variables.read().await;
5118            (
5119                vars.get("user").cloned(),
5120                vars.get("database")
5121                    .cloned()
5122                    .unwrap_or_else(|| "default".to_string()),
5123            )
5124        };
5125        Some(crate::cache::CacheContext {
5126            database,
5127            user,
5128            branch: None,
5129            connection_id: Some(session.id.as_u64_pair().0),
5130        })
5131    }
5132
5133    /// Build a multi-tenancy `RequestContext` from the session's startup
5134    /// parameters (user, database, application_name, ...) so the configured
5135    /// identifier can resolve the tenant.
5136    #[cfg(feature = "multi-tenancy")]
5137    async fn tenant_request_ctx(
5138        session: &Arc<ClientSession>,
5139    ) -> crate::multi_tenancy::RequestContext {
5140        let vars = session.variables.read().await;
5141        crate::multi_tenancy::RequestContext {
5142            headers: vars.clone(),
5143            username: vars.get("user").cloned(),
5144            database: vars.get("database").cloned(),
5145            auth_token: None,
5146            sql_context: HashMap::new(),
5147            client_ip: Some(session.client_addr.ip().to_string()),
5148            connection_id: Some(session.id.as_u64_pair().0),
5149        }
5150    }
5151
5152    /// Journal a successful write statement (Transaction Replay). Each write is
5153    /// recorded as its own auto-commit transaction so the time-travel/failover
5154    /// replay engine can re-apply it onto a promoted primary or a staging
5155    /// target. Best-effort: journal errors never fail the client query.
5156    #[cfg(feature = "ha-tr")]
5157    async fn journal_write(state: &Arc<ServerState>, session: &Arc<ClientSession>, sql: &str) {
5158        let tx_id = uuid::Uuid::new_v4();
5159        let j = &state.transaction_journal;
5160        if j.begin_transaction(tx_id, session.id, crate::NodeId::new(), 0)
5161            .await
5162            .is_ok()
5163        {
5164            let _ = j
5165                .log_statement(tx_id, sql.to_string(), Vec::new(), None, None, 0)
5166                .await;
5167        }
5168    }
5169
5170    /// Record a forwarded query on the analytics engine (fingerprint, latency,
5171    /// slow-query log, pattern detection). No-op when analytics is disabled.
5172    #[cfg(feature = "query-analytics")]
5173    async fn record_analytics(
5174        state: &Arc<ServerState>,
5175        session: &Arc<ClientSession>,
5176        sql: &str,
5177        node: &str,
5178        duration: Duration,
5179        error: Option<String>,
5180    ) {
5181        let Some(analytics) = state.analytics.as_ref() else {
5182            return;
5183        };
5184        let (user, database) = {
5185            let vars = session.variables.read().await;
5186            (
5187                vars.get("user").cloned().unwrap_or_default(),
5188                vars.get("database").cloned().unwrap_or_default(),
5189            )
5190        };
5191        let mut exec = crate::analytics::QueryExecution::new(sql, duration);
5192        exec.user = user;
5193        exec.database = database;
5194        exec.client_ip = session.client_addr.ip().to_string();
5195        exec.node = node.to_string();
5196        exec.session_id = Some(session.id.to_string());
5197        exec.error = error;
5198        analytics.record(exec);
5199    }
5200
5201    /// Select primary node with write timeout during failover
5202    async fn select_primary_with_timeout(
5203        session: &Arc<ClientSession>,
5204        state: &Arc<ServerState>,
5205        config: &ProxyConfig,
5206    ) -> Result<String> {
5207        let timeout = config.write_timeout();
5208        let start = std::time::Instant::now();
5209        // Poll for the promoted primary fairly tightly so writes resume
5210        // quickly after a failover (was 500ms — a needless recovery floor).
5211        let check_interval = Duration::from_millis(100);
5212
5213        loop {
5214            // Try to find healthy primary
5215            let health = state.health.load_full();
5216            let primary = config
5217                .nodes
5218                .iter()
5219                .find(|n| n.role == NodeRole::Primary && n.enabled);
5220
5221            if let Some(primary_node) = primary {
5222                if let Some(node_health) = health.get(&primary_node.address()) {
5223                    if node_health.healthy {
5224                        // Update session's current node
5225                        let mut current = session.current_node.write().await;
5226                        *current = Some(primary_node.address());
5227                        return Ok(primary_node.address());
5228                    }
5229                }
5230            }
5231            drop(health);
5232
5233            // Check if timeout exceeded
5234            if start.elapsed() >= timeout {
5235                state.metrics.failovers.fetch_add(1, Ordering::Relaxed);
5236                return Err(ProxyError::NoHealthyNodes);
5237            }
5238
5239            tracing::warn!(
5240                "Primary unavailable, waiting for failover... ({:.1}s elapsed, {:.1}s timeout)",
5241                start.elapsed().as_secs_f64(),
5242                timeout.as_secs_f64()
5243            );
5244
5245            // Wait before retry
5246            tokio::time::sleep(check_interval).await;
5247        }
5248    }
5249
5250    /// Select node for read operations with load balancing
5251    async fn select_read_node(
5252        session: &Arc<ClientSession>,
5253        state: &Arc<ServerState>,
5254        config: &ProxyConfig,
5255    ) -> Result<String> {
5256        // If in transaction, stick to current node
5257        if session
5258            .in_transaction
5259            .load(std::sync::atomic::Ordering::Relaxed)
5260        {
5261            if let Some(node) = session.current_node.read().await.clone() {
5262                return Ok(node);
5263            }
5264        }
5265
5266        // Get healthy nodes (prefer standbys for reads)
5267        let health = state.health.load_full();
5268        let healthy_standbys: Vec<&NodeConfig> = config
5269            .nodes
5270            .iter()
5271            .filter(|n| {
5272                let base = n.enabled
5273                    && (n.role == NodeRole::Standby || n.role == NodeRole::ReadReplica)
5274                    && health.get(&n.address()).map(|h| h.healthy).unwrap_or(false);
5275                // Drop a standby whose circuit is open so reads avoid it.
5276                #[cfg(feature = "circuit-breaker")]
5277                let base = base && !Self::circuit_is_open(state, &n.address());
5278                // Drop a standby lagging beyond the configured byte threshold.
5279                #[cfg(feature = "lag-routing")]
5280                let base = base
5281                    && !Self::lag_excludes_standby(
5282                        health
5283                            .get(&n.address())
5284                            .and_then(|h| h.replication_lag_bytes),
5285                        config.lag_routing.max_lag_bytes,
5286                    );
5287                base
5288            })
5289            .collect();
5290
5291        if !healthy_standbys.is_empty() {
5292            // Round-robin across healthy standbys
5293            let ticket = state.lb_state.rr_counter.fetch_add(1, Ordering::Relaxed);
5294            let index = ticket as usize % healthy_standbys.len();
5295            let node_addr = healthy_standbys[index].address();
5296
5297            let mut current = session.current_node.write().await;
5298            *current = Some(node_addr.clone());
5299            return Ok(node_addr);
5300        }
5301
5302        // Fall back to primary if no healthy standbys
5303        Self::select_node(session, state, config).await
5304    }
5305
5306    /// Complete backend authentication by reading until ReadyForQuery
5307    /// This is used when switching backends - we don't forward auth to client
5308    async fn complete_backend_auth(backend: &mut TcpStream) -> Result<()> {
5309        let mut buffer = BytesMut::with_capacity(4096);
5310        let timeout = Duration::from_secs(10);
5311        let start = std::time::Instant::now();
5312
5313        loop {
5314            if start.elapsed() > timeout {
5315                return Err(ProxyError::Auth(
5316                    "Backend authentication timeout".to_string(),
5317                ));
5318            }
5319
5320            buffer.reserve(4096);
5321            let n = tokio::time::timeout(Duration::from_secs(5), backend.read_buf(&mut buffer))
5322                .await
5323                .map_err(|_| ProxyError::Auth("Read timeout during backend auth".to_string()))?
5324                .map_err(|e| ProxyError::Network(format!("Backend auth read error: {}", e)))?;
5325
5326            if n == 0 {
5327                return Err(ProxyError::Connection(
5328                    "Backend closed during auth".to_string(),
5329                ));
5330            }
5331
5332            // Walk complete frames by raw tag. The wire decoder is
5333            // direction-agnostic ('E' decodes to the client-side `Execute`), so
5334            // a backend ErrorResponse must be detected by its raw tag rather
5335            // than by `msg_type` — the previous version matched
5336            // `MessageType::ErrorResponse`, which never fired, so a failed
5337            // backend auth surfaced as a misleading timeout.
5338            loop {
5339                if buffer.len() < 5 {
5340                    break;
5341                }
5342                let len = u32::from_be_bytes([buffer[1], buffer[2], buffer[3], buffer[4]]) as usize;
5343                if len < 4 || buffer.len() < len + 1 {
5344                    break;
5345                }
5346                let tag = buffer[0];
5347                let frame = buffer.split_to(len + 1);
5348                match tag {
5349                    // ReadyForQuery: authentication complete.
5350                    b'Z' => return Ok(()),
5351                    // ErrorResponse: parse its message for a clear error.
5352                    b'E' => {
5353                        let payload = BytesMut::from(&frame[5..]);
5354                        let err = ErrorResponse::parse(payload)
5355                            .map(|e| e.message().unwrap_or("Unknown error").to_string())
5356                            .unwrap_or_else(|_| "authentication failed".to_string());
5357                        return Err(ProxyError::Auth(err));
5358                    }
5359                    _ => {}
5360                }
5361            }
5362        }
5363    }
5364
5365    /// Create PostgreSQL error response message
5366    fn create_error_response(code: &str, message: &str) -> Vec<u8> {
5367        let mut fields = HashMap::new();
5368        fields.insert('S', "ERROR".to_string());
5369        fields.insert('V', "ERROR".to_string());
5370        fields.insert('C', code.to_string());
5371        fields.insert('M', message.to_string());
5372
5373        let err = ErrorResponse { fields };
5374        err.encode().encode().to_vec()
5375    }
5376
5377    /// Create a `ReadyForQuery` frame with the given transaction-status byte
5378    /// (`b'I'` = idle, `b'T'` = in transaction, `b'E'` = failed transaction).
5379    fn create_ready_for_query(status: u8) -> Vec<u8> {
5380        let mut payload = BytesMut::with_capacity(1);
5381        payload.put_u8(status);
5382        Message::new(MessageType::ReadyForQuery, payload)
5383            .encode()
5384            .to_vec()
5385    }
5386
5387    /// Synthesise a full PostgreSQL simple-query response from a cached
5388    /// payload produced by a plugin's `PreQueryResult::Cached`.
5389    ///
5390    /// # Payload format
5391    ///
5392    /// The plugin is expected to serialise a JSON document of the form:
5393    ///
5394    /// ```json
5395    /// {
5396    ///   "columns": [
5397    ///     {"name": "id",    "oid": 23},
5398    ///     {"name": "email", "oid": 25}
5399    ///   ],
5400    ///   "rows": [
5401    ///     ["1", "alice@example.com"],
5402    ///     ["2", null]
5403    ///   ]
5404    /// }
5405    /// ```
5406    ///
5407    /// `oid` is the PostgreSQL type OID (`23` = int4, `25` = text,
5408    /// `20` = int8, `16` = bool, `1184` = timestamptz, etc.). Row values
5409    /// are strings in text format; `null` encodes a SQL NULL. The type
5410    /// OID is advisory — pgwire clients accept `25` (text) universally
5411    /// and cast as needed.
5412    ///
5413    /// # Returned bytes
5414    ///
5415    /// One concatenated PostgreSQL wire response:
5416    ///
5417    /// ```text
5418    /// RowDescription (T) + DataRow (D) × N + CommandComplete (C: "SELECT N")
5419    ///                    + ReadyForQuery (Z: idle)
5420    /// ```
5421    ///
5422    /// Returns an error on malformed JSON; the caller falls back to
5423    /// backend forwarding.
5424    #[cfg(feature = "wasm-plugins")]
5425    fn synthesise_cached_response(bytes: &[u8]) -> Result<Vec<u8>> {
5426        use serde::Deserialize;
5427
5428        #[derive(Deserialize)]
5429        struct CachedPayload {
5430            columns: Vec<ColumnDef>,
5431            rows: Vec<Vec<Option<String>>>,
5432        }
5433
5434        #[derive(Deserialize)]
5435        struct ColumnDef {
5436            name: String,
5437            #[serde(default = "default_text_oid")]
5438            oid: u32,
5439        }
5440
5441        fn default_text_oid() -> u32 {
5442            25 // text
5443        }
5444
5445        let payload: CachedPayload = serde_json::from_slice(bytes)
5446            .map_err(|e| ProxyError::Protocol(format!("invalid cached payload JSON: {}", e)))?;
5447
5448        if payload.columns.is_empty() {
5449            return Err(ProxyError::Protocol(
5450                "cached payload must declare at least one column".to_string(),
5451            ));
5452        }
5453
5454        let mut reply = Vec::new();
5455
5456        // RowDescription (tag 'T')
5457        let mut rd = BytesMut::new();
5458        rd.put_u16(payload.columns.len() as u16);
5459        for col in &payload.columns {
5460            rd.extend_from_slice(col.name.as_bytes());
5461            rd.put_u8(0); // cstring terminator
5462            rd.put_i32(0); // tableOID (unknown)
5463            rd.put_i16(0); // columnNumber (unknown)
5464            rd.put_u32(col.oid);
5465            rd.put_i16(-1); // typeLen (unspecified)
5466            rd.put_i32(-1); // typeMod (unspecified)
5467            rd.put_i16(0); // format code: text
5468        }
5469        reply.extend_from_slice(&Message::new(MessageType::RowDescription, rd).encode());
5470
5471        // DataRow (tag 'D') per row
5472        let column_count = payload.columns.len();
5473        for row in &payload.rows {
5474            if row.len() != column_count {
5475                return Err(ProxyError::Protocol(format!(
5476                    "cached row has {} values but {} columns are declared",
5477                    row.len(),
5478                    column_count
5479                )));
5480            }
5481            let mut dr = BytesMut::new();
5482            dr.put_u16(row.len() as u16);
5483            for value in row {
5484                match value {
5485                    Some(s) => {
5486                        dr.put_i32(s.len() as i32);
5487                        dr.extend_from_slice(s.as_bytes());
5488                    }
5489                    None => {
5490                        dr.put_i32(-1); // NULL sentinel
5491                    }
5492                }
5493            }
5494            reply.extend_from_slice(&Message::new(MessageType::DataRow, dr).encode());
5495        }
5496
5497        // CommandComplete (tag 'C')
5498        let tag = format!("SELECT {}", payload.rows.len());
5499        let mut cc = BytesMut::new();
5500        cc.extend_from_slice(tag.as_bytes());
5501        cc.put_u8(0);
5502        reply.extend_from_slice(&Message::new(MessageType::CommandComplete, cc).encode());
5503
5504        // ReadyForQuery (tag 'Z', status 'I' idle)
5505        reply.extend_from_slice(&Self::create_ready_for_query(b'I'));
5506
5507        Ok(reply)
5508    }
5509
5510    /// Run the pre-query plugin hook on a client message.
5511    ///
5512    /// When the `wasm-plugins` feature is off, or the plugin manager has no
5513    /// loaded plugins, this is a zero-cost passthrough that returns the
5514    /// message untouched with `PreQueryAction::Forward`.
5515    ///
5516    /// Only simple-query (`MessageType::Query`) messages are inspected today.
5517    /// Extended-protocol messages (`Parse`/`Bind`/`Execute`) are passed
5518    /// through unchanged — a future task wires them in.
5519    fn apply_pre_query_hook(
5520        msg: Message,
5521        state: &Arc<ServerState>,
5522        session: &Arc<ClientSession>,
5523    ) -> (Message, PreQueryAction) {
5524        #[cfg(feature = "wasm-plugins")]
5525        {
5526            let pm = match state.plugin_manager.as_ref() {
5527                Some(pm) => pm,
5528                None => return (msg, PreQueryAction::Forward),
5529            };
5530
5531            if msg.msg_type != MessageType::Query {
5532                return (msg, PreQueryAction::Forward);
5533            }
5534
5535            // Zero plugins registered for this hook — skip the payload
5536            // clone, SQL parse, and context construction entirely.
5537            if !pm.has_hook(HookType::PreQuery) {
5538                return (msg, PreQueryAction::Forward);
5539            }
5540
5541            let query_msg = match QueryMessage::parse(msg.payload.clone()) {
5542                Ok(q) => q,
5543                Err(_) => return (msg, PreQueryAction::Forward),
5544            };
5545
5546            let ctx = Self::build_query_context(&query_msg.query, session);
5547
5548            match pm.execute_pre_query(&ctx) {
5549                PreQueryResult::Continue => (msg, PreQueryAction::Forward),
5550                PreQueryResult::Block(reason) => (msg, PreQueryAction::Block(reason)),
5551                PreQueryResult::Rewrite(new_sql) => {
5552                    let rewritten = QueryMessage { query: new_sql }.encode();
5553                    (rewritten, PreQueryAction::Forward)
5554                }
5555                PreQueryResult::Cached(bytes) => (msg, PreQueryAction::Cached(bytes)),
5556            }
5557        }
5558        #[cfg(not(feature = "wasm-plugins"))]
5559        {
5560            let _ = (state, session);
5561            (msg, PreQueryAction::Forward)
5562        }
5563    }
5564
5565    /// Feed the anomaly detector a per-query observation. Cheap —
5566    /// only the SQL-injection scan and the novel-fingerprint check
5567    /// are non-trivial, both well under a microsecond on
5568    /// representative queries. Returns nothing; detections land in
5569    /// the detector's ring buffer and are surfaced via /api/anomalies.
5570    #[cfg(feature = "anomaly-detection")]
5571    fn record_anomaly_observation(
5572        msg: &Message,
5573        state: &Arc<ServerState>,
5574        session: &Arc<ClientSession>,
5575    ) {
5576        if msg.msg_type != MessageType::Query {
5577            return;
5578        }
5579        // Borrow the SQL straight out of the payload — the message is
5580        // forwarded verbatim, so no deep copy of the frame is needed.
5581        if let Some(query) = crate::protocol::query_text(&msg.payload) {
5582            Self::record_anomaly_sql(query, state, session);
5583        }
5584    }
5585
5586    /// Feed one SQL statement to the anomaly detector. Shared by the
5587    /// simple-query path and the extended-protocol `Parse` path so
5588    /// prepared-statement traffic is observed too.
5589    #[cfg(feature = "anomaly-detection")]
5590    fn record_anomaly_sql(query: &str, state: &Arc<ServerState>, session: &Arc<ClientSession>) {
5591        // Tenant identifier is the most-specific known per-session
5592        // attribute the proxy can attribute traffic to. Multi-tenancy
5593        // sets `tenant_id` in `variables`; otherwise we fall back to
5594        // the client address. session.variables is a tokio RwLock but this
5595        // is a sync helper — try_read avoids an await; on contention we
5596        // fall back to the client IP, still a valid per-source identifier.
5597        let tenant = match session.variables.try_read() {
5598            Ok(vars) => vars
5599                .get("tenant_id")
5600                .or_else(|| vars.get("user"))
5601                .cloned()
5602                .unwrap_or_else(|| session.client_addr.ip().to_string()),
5603            Err(_) => session.client_addr.ip().to_string(),
5604        };
5605        let fingerprint = anomaly_fingerprint(query);
5606        let obs = crate::anomaly::QueryObservation {
5607            tenant,
5608            fingerprint,
5609            sql: query.to_string(),
5610            timestamp: std::time::Instant::now(),
5611        };
5612        for ev in state.anomaly_detector.record_query(&obs) {
5613            tracing::warn!(anomaly = ?ev, "anomaly detected");
5614        }
5615    }
5616
5617    /// Send the client a `Block`-outcome response: an error frame plus
5618    /// `ReadyForQuery` so the client's state machine returns to idle and
5619    /// the next query can be accepted.
5620    async fn send_block_response(
5621        stream: &mut ClientStream,
5622        reason: &str,
5623        state: &Arc<ServerState>,
5624    ) -> Result<()> {
5625        let err =
5626            Self::create_error_response("42000", &format!("Query blocked by plugin: {}", reason));
5627        stream
5628            .write_all(&err)
5629            .await
5630            .map_err(|e| ProxyError::Network(format!("Write error: {}", e)))?;
5631        let rfq = Self::create_ready_for_query(b'I');
5632        stream
5633            .write_all(&rfq)
5634            .await
5635            .map_err(|e| ProxyError::Network(format!("Write error: {}", e)))?;
5636        state
5637            .metrics
5638            .bytes_sent
5639            .fetch_add((err.len() + rfq.len()) as u64, Ordering::Relaxed);
5640        Ok(())
5641    }
5642
5643    /// Build a `QueryContext` for the plugin hook. Populated fields: `query`
5644    /// (verbatim), `is_read_only` (derived from SQL verb), and `hook_context`
5645    /// with the session id as `client_id`. `normalized` and `tables` are
5646    /// left as cheap stand-ins until the analytics normaliser is wired in
5647    /// (T0-d, unified context).
5648    #[cfg(feature = "wasm-plugins")]
5649    fn build_query_context(query: &str, session: &Arc<ClientSession>) -> QueryContext {
5650        let is_read_only = !Self::is_write_query(query);
5651        let hook_context = HookContext {
5652            client_id: Some(session.id.to_string()),
5653            ..HookContext::default()
5654        };
5655        QueryContext {
5656            query: query.to_string(),
5657            normalized: query.to_string(),
5658            tables: Vec::new(),
5659            is_read_only,
5660            hook_context,
5661        }
5662    }
5663
5664    /// Run the Authenticate plugin hook at startup. Called from
5665    /// `connect_and_authenticate` before any backend connection.
5666    ///
5667    /// Behaviour by `AuthResult`:
5668    /// * `Defer` — no plugin opinion; proceed with the default
5669    ///   PostgreSQL auth flow unchanged.
5670    /// * `Success(identity)` — store the identity on the session so
5671    ///   downstream plugins (masking, residency) can gate on roles /
5672    ///   tenant_id / claims. PostgreSQL backend auth still runs
5673    ///   normally afterwards (the plugin does not replace PG auth in
5674    ///   this iteration; that's a follow-up).
5675    /// * `Denied(reason)` — surfaces as `ProxyError::Auth`, which the
5676    ///   caller already handles by writing an ErrorResponse to the
5677    ///   client and closing the connection.
5678    ///
5679    /// The `AuthRequest` populated here carries username, database,
5680    /// and client IP from the PostgreSQL startup parameters. Password
5681    /// is deliberately `None` — PG protocol sends the password in
5682    /// response to the backend's challenge, not at startup, so
5683    /// password-aware plugin auth is a separate future task.
5684    async fn apply_authenticate_hook(
5685        _params: &HashMap<String, String>,
5686        _session: &Arc<ClientSession>,
5687        _state: &Arc<ServerState>,
5688    ) -> Result<()> {
5689        #[cfg(feature = "wasm-plugins")]
5690        {
5691            let pm = match _state.plugin_manager.as_ref() {
5692                Some(pm) => pm,
5693                None => return Ok(()),
5694            };
5695
5696            let request = PluginAuthRequest {
5697                headers: HashMap::new(),
5698                username: _params.get("user").cloned(),
5699                password: None,
5700                client_ip: _session.client_addr.ip().to_string(),
5701                database: _params.get("database").cloned(),
5702            };
5703
5704            match pm.execute_authenticate(&request) {
5705                AuthResult::Defer => Ok(()),
5706                AuthResult::Success(identity) => {
5707                    tracing::debug!(
5708                        user = %identity.username,
5709                        roles = ?identity.roles,
5710                        "plugin authenticated user"
5711                    );
5712                    *_session.plugin_identity.write().await = Some(identity);
5713                    Ok(())
5714                }
5715                AuthResult::Denied(reason) => {
5716                    tracing::info!(
5717                        reason = %reason,
5718                        client = %_session.client_addr,
5719                        user = ?_params.get("user"),
5720                        "plugin denied authentication"
5721                    );
5722                    Err(ProxyError::Auth(format!(
5723                        "authentication denied by plugin: {}",
5724                        reason
5725                    )))
5726                }
5727            }
5728        }
5729        #[cfg(not(feature = "wasm-plugins"))]
5730        {
5731            Ok(())
5732        }
5733    }
5734
5735    /// Run the Route plugin hook on a message. Only simple-query messages
5736    /// are inspected; other message types always return `None`.
5737    fn apply_route_hook(
5738        msg: &Message,
5739        state: &Arc<ServerState>,
5740        session: &Arc<ClientSession>,
5741    ) -> RouteOverride {
5742        #[cfg(feature = "wasm-plugins")]
5743        {
5744            let pm = match state.plugin_manager.as_ref() {
5745                Some(pm) => pm,
5746                None => return RouteOverride::None,
5747            };
5748            if msg.msg_type != MessageType::Query {
5749                return RouteOverride::None;
5750            }
5751            // Zero plugins registered for this hook — skip the payload
5752            // clone, SQL parse, and context construction entirely.
5753            if !pm.has_hook(HookType::Route) {
5754                return RouteOverride::None;
5755            }
5756            let query_msg = match QueryMessage::parse(msg.payload.clone()) {
5757                Ok(q) => q,
5758                Err(_) => return RouteOverride::None,
5759            };
5760            let ctx = Self::build_query_context(&query_msg.query, session);
5761            match pm.execute_route(&ctx) {
5762                RouteResult::Default => RouteOverride::None,
5763                RouteResult::Primary => RouteOverride::Primary,
5764                RouteResult::Standby => RouteOverride::Standby,
5765                RouteResult::Node(name) => RouteOverride::Node(name),
5766                RouteResult::Block(reason) => RouteOverride::Block(reason),
5767                RouteResult::Branch(name) => {
5768                    tracing::warn!(
5769                        branch = %name,
5770                        "Route hook returned Branch but branch routing is not yet wired — using default"
5771                    );
5772                    RouteOverride::None
5773                }
5774            }
5775        }
5776        #[cfg(not(feature = "wasm-plugins"))]
5777        {
5778            let _ = (msg, state, session);
5779            RouteOverride::None
5780        }
5781    }
5782
5783    /// Map parsed SQL-comment hints to a `RouteOverride`. Precedence:
5784    /// `node=` > `route=` > `consistency=strong`. Read-tier route targets
5785    /// (standby/sync/semisync/async/local) all map to the read path; `any`
5786    /// and `vector` impose no constraint. `lag=` / `consistency=bounded`
5787    /// freshness enforcement arrives with the lag-routing feature.
5788    #[cfg(feature = "routing-hints")]
5789    fn hint_to_override(hints: &crate::routing::ParsedHints) -> RouteOverride {
5790        use crate::routing::{ConsistencyLevel, RouteTarget};
5791        if let Some(node) = &hints.node {
5792            return RouteOverride::Node(node.clone());
5793        }
5794        if let Some(route) = hints.route {
5795            return match route {
5796                RouteTarget::Primary => RouteOverride::Primary,
5797                RouteTarget::Standby
5798                | RouteTarget::Sync
5799                | RouteTarget::SemiSync
5800                | RouteTarget::Async
5801                | RouteTarget::Local => RouteOverride::Standby,
5802                RouteTarget::Any | RouteTarget::Vector => RouteOverride::None,
5803            };
5804        }
5805        if hints.consistency == Some(ConsistencyLevel::Strong) {
5806            return RouteOverride::Primary;
5807        }
5808        RouteOverride::None
5809    }
5810
5811    /// Resolve the effective routing for a simple `Query` when the
5812    /// routing-hints feature is active. Returns `(override, is_write,
5813    /// forward_msg)`: the write flag is recomputed on the hint-stripped SQL so
5814    /// a leading hint comment never masks the verb, and `forward_msg` is a
5815    /// rebuilt `Query` (hint removed) when stripping is on. An explicit
5816    /// positional hint wins over a plugin route override; a plugin `Block` is
5817    /// handled by the caller before this runs.
5818    #[cfg(feature = "routing-hints")]
5819    fn resolve_simple_route(
5820        msg: &Message,
5821        plugin_override: RouteOverride,
5822        default_is_write: bool,
5823        state: &Arc<ServerState>,
5824    ) -> (RouteOverride, bool, Option<Message>) {
5825        let parser = match state.hint_parser.as_ref() {
5826            Some(p) => p,
5827            None => return (plugin_override, default_is_write, None),
5828        };
5829        let sql = match crate::protocol::query_text(&msg.payload) {
5830            Some(s) => s,
5831            None => return (plugin_override, default_is_write, None),
5832        };
5833        let hints = parser.parse(sql);
5834        if hints.is_empty() {
5835            return (plugin_override, default_is_write, None);
5836        }
5837        let stripped = parser.strip(sql);
5838        let is_write = Self::is_write_query(&stripped);
5839        let effective = match Self::hint_to_override(&hints) {
5840            RouteOverride::None => plugin_override,
5841            hint_override => hint_override,
5842        };
5843        let forward = if parser.strip_hints {
5844            Some(crate::protocol::QueryMessage { query: stripped }.encode())
5845        } else {
5846            None
5847        };
5848        (effective, is_write, forward)
5849    }
5850
5851    /// Resolve hint-driven routing for an extended-protocol batch from the
5852    /// first Parse's SQL. `Some((is_write, forced_node))` when hints are
5853    /// present (write flag computed on the stripped SQL), else `None` so the
5854    /// caller uses verb-based defaults. The hint comment is left in the
5855    /// forwarded `Parse` (a no-op SQL comment); rewriting the batch buffer is
5856    /// unnecessary for correctness.
5857    #[cfg(feature = "routing-hints")]
5858    fn extended_hint_route(state: &Arc<ServerState>, sql: &str) -> Option<(bool, Option<String>)> {
5859        let parser = state.hint_parser.as_ref()?;
5860        let hints = parser.parse(sql);
5861        if hints.is_empty() {
5862            return None;
5863        }
5864        let stripped = parser.strip(sql);
5865        let is_write = Self::is_write_query(&stripped);
5866        match Self::hint_to_override(&hints) {
5867            RouteOverride::Primary => Some((true, None)),
5868            RouteOverride::Standby => Some((false, None)),
5869            RouteOverride::Node(n) => Some((is_write, Some(n))),
5870            _ => Some((is_write, None)),
5871        }
5872    }
5873
5874    /// Fire post-query hooks after a message has been forwarded (or failed
5875    /// to forward). Best-effort; errors from individual plugins are logged
5876    /// by the plugin manager and never surface here.
5877    #[cfg(feature = "wasm-plugins")]
5878    fn fire_post_query_hook(
5879        msg: &Message,
5880        session: &Arc<ClientSession>,
5881        state: &Arc<ServerState>,
5882        result: &Result<(Option<String>, u64)>,
5883        elapsed: Duration,
5884    ) {
5885        let pm = match state.plugin_manager.as_ref() {
5886            Some(pm) => pm,
5887            None => return,
5888        };
5889        if msg.msg_type != MessageType::Query {
5890            return;
5891        }
5892        // Zero plugins registered for this hook — skip the payload
5893        // clone, SQL parse, and context construction entirely.
5894        if !pm.has_hook(HookType::PostQuery) {
5895            return;
5896        }
5897        let query_msg = match QueryMessage::parse(msg.payload.clone()) {
5898            Ok(q) => q,
5899            Err(_) => return,
5900        };
5901        let ctx = Self::build_query_context(&query_msg.query, session);
5902        let outcome = match result {
5903            Ok((node, bytes)) => PostQueryOutcome {
5904                success: true,
5905                target_node: node.clone(),
5906                elapsed_us: elapsed.as_micros() as u64,
5907                response_bytes: *bytes,
5908                error: None,
5909            },
5910            Err(e) => PostQueryOutcome {
5911                success: false,
5912                target_node: None,
5913                elapsed_us: elapsed.as_micros() as u64,
5914                response_bytes: 0,
5915                error: Some(e.to_string()),
5916            },
5917        };
5918        pm.execute_post_query(&ctx, &outcome);
5919    }
5920
5921    /// Select a backend node for the request
5922    /// Select a backend node for initial connection
5923    /// Prefers primary but falls back to standbys for read connections
5924    async fn select_node(
5925        session: &Arc<ClientSession>,
5926        state: &Arc<ServerState>,
5927        config: &ProxyConfig,
5928    ) -> Result<String> {
5929        // If in a transaction, stick to the current node
5930        if session
5931            .in_transaction
5932            .load(std::sync::atomic::Ordering::Relaxed)
5933        {
5934            if let Some(node) = session.current_node.read().await.clone() {
5935                return Ok(node);
5936            }
5937        }
5938
5939        // Get healthy nodes
5940        let health = state.health.load_full();
5941        let healthy_nodes: Vec<&NodeConfig> = config
5942            .nodes
5943            .iter()
5944            .filter(|n| n.enabled && health.get(&n.address()).map(|h| h.healthy).unwrap_or(false))
5945            .collect();
5946
5947        if healthy_nodes.is_empty() {
5948            return Err(ProxyError::NoHealthyNodes);
5949        }
5950
5951        // Try to find healthy primary first
5952        if let Some(primary) = healthy_nodes.iter().find(|n| n.role == NodeRole::Primary) {
5953            let node_addr = primary.address();
5954            let mut current = session.current_node.write().await;
5955            *current = Some(node_addr.clone());
5956            return Ok(node_addr);
5957        }
5958
5959        // Fall back to standby if primary is unavailable
5960        // (Initial connection will work, writes will use write timeout to wait for primary)
5961        if let Some(standby) = healthy_nodes.iter().find(|n| n.role == NodeRole::Standby) {
5962            tracing::warn!("Primary unavailable, connecting to standby for initial session");
5963            let node_addr = standby.address();
5964            let mut current = session.current_node.write().await;
5965            *current = Some(node_addr.clone());
5966            return Ok(node_addr);
5967        }
5968
5969        // No nodes available
5970        Err(ProxyError::NoHealthyNodes)
5971    }
5972
5973    /// Spawn health checker background task
5974    fn spawn_health_checker(&self) -> tokio::task::JoinHandle<()> {
5975        let state = self.state.clone();
5976        let mut shutdown_rx = self.shutdown_tx.subscribe();
5977
5978        tokio::spawn(async move {
5979            // Clamp to a 1s floor: `tokio::time::interval` panics on a zero
5980            // period, which would silently kill this task (it would then never
5981            // probe, so the proxy keeps routing to dead backends). `validate()`
5982            // already rejects 0 in a file config; this defends a
5983            // programmatically-built or reloaded config too.
5984            let interval_secs = state.live_config.load().health.check_interval_secs.max(1);
5985            let mut interval = tokio::time::interval(std::time::Duration::from_secs(interval_secs));
5986
5987            loop {
5988                tokio::select! {
5989                    _ = interval.tick() => {
5990                        // Read the live config each tick so a SIGHUP that
5991                        // adds/removes nodes is checked on the next sweep.
5992                        let config = state.live_config.load_full();
5993                        // Run the sweep in a child task so an unexpected panic in
5994                        // check_all_nodes surfaces as a JoinError and is logged —
5995                        // the health loop keeps running instead of dying silently
5996                        // and freezing health at its last snapshot forever.
5997                        let st = state.clone();
5998                        if let Err(e) = tokio::spawn(async move {
5999                            Self::check_all_nodes(&st, &config).await;
6000                        })
6001                        .await
6002                        {
6003                            tracing::error!(error = %e, "health-check sweep panicked; health loop continuing");
6004                        }
6005                    }
6006                    _ = shutdown_rx.recv() => {
6007                        break;
6008                    }
6009                }
6010            }
6011        })
6012    }
6013
6014    /// Check health of all nodes.
6015    ///
6016    /// Probes run concurrently (one slow/unreachable node no longer delays
6017    /// detection on the others — lowers the failover-detection latency
6018    /// floor), then a single new health snapshot is published via ArcSwap so
6019    /// readers on the query path never block.
6020    async fn check_all_nodes(state: &Arc<ServerState>, config: &ProxyConfig) {
6021        // Probe every node in parallel (owned address + timeout so each
6022        // probe is 'static and runs on its own task).
6023        let timeout = Duration::from_secs(config.health.check_timeout_secs);
6024        let mut set = tokio::task::JoinSet::new();
6025        for node in &config.nodes {
6026            let addr = node.address();
6027            set.spawn(async move {
6028                let r = Self::check_node_addr(&addr, timeout).await;
6029                (addr, r)
6030            });
6031        }
6032        let mut results = Vec::with_capacity(config.nodes.len());
6033        while let Some(joined) = set.join_next().await {
6034            if let Ok(pair) = joined {
6035                results.push(pair);
6036            }
6037        }
6038
6039        // Clone-and-modify the current snapshot, then atomically swap it in.
6040        // Hold the write lock so a concurrent in-band demotion landing in this
6041        // load→store window (or a SIGHUP reconcile) cannot clobber, or be
6042        // clobbered by, this full-map rebuild. All node probing above already
6043        // completed; no await is held under the guard.
6044        let _writers = state.health_write.lock();
6045        let mut next = (*state.health.load_full()).clone();
6046        for (addr, result) in results {
6047            if let Some(node_health) = next.get_mut(&addr) {
6048                match result {
6049                    Ok(latency) => {
6050                        node_health.healthy = true;
6051                        node_health.failure_count = 0;
6052                        node_health.latency_ms = latency;
6053                        node_health.last_error = None;
6054                    }
6055                    Err(e) => {
6056                        node_health.failure_count += 1;
6057                        node_health.last_error = Some(e.to_string());
6058                        if node_health.failure_count >= config.health.failure_threshold {
6059                            node_health.healthy = false;
6060                            tracing::warn!(
6061                                "Node {} marked unhealthy after {} failures",
6062                                addr,
6063                                node_health.failure_count
6064                            );
6065                        }
6066                    }
6067                }
6068                node_health.last_check = chrono::Utc::now();
6069            }
6070        }
6071        state.health.store(Arc::new(next));
6072    }
6073
6074    /// Check health of a single node with a protocol-level liveness probe.
6075    ///
6076    /// A bare TCP connect is not enough: a wedged backend (postmaster stuck,
6077    /// out of backend slots, mid-crash-recovery) still *accepts* the socket but
6078    /// never processes the wire protocol, so a connect-only probe reports it
6079    /// healthy. Instead we connect, send a PostgreSQL `SSLRequest`, and require
6080    /// the postmaster to answer (`S`/`N`) within the timeout. The SSLRequest is
6081    /// auth-free and not logged, so it costs the backend essentially nothing,
6082    /// yet it proves the server is actually servicing the protocol. Returns the
6083    /// round-trip latency in milliseconds.
6084    async fn check_node_addr(addr: &str, timeout: Duration) -> Result<f64> {
6085        // length(8) + SSLRequest code 80877103 (0x04D2162F).
6086        const SSL_REQUEST: [u8; 8] = [0, 0, 0, 8, 0x04, 0xD2, 0x16, 0x2F];
6087        let start = std::time::Instant::now();
6088        let mut stream = tokio::time::timeout(timeout, TcpStream::connect(addr))
6089            .await
6090            .map_err(|_| ProxyError::HealthCheck(format!("Timeout connecting to {}", addr)))?
6091            .map_err(|e| {
6092                ProxyError::HealthCheck(format!("Failed to connect to {}: {}", addr, e))
6093            })?;
6094
6095        let probe = async {
6096            stream.write_all(&SSL_REQUEST).await?;
6097            let mut resp = [0u8; 1];
6098            stream.read_exact(&mut resp).await?;
6099            Ok::<u8, std::io::Error>(resp[0])
6100        };
6101        // Budget whatever time is left after the connect for the handshake.
6102        let remaining = timeout
6103            .saturating_sub(start.elapsed())
6104            .max(Duration::from_millis(1));
6105        let byte = tokio::time::timeout(remaining, probe)
6106            .await
6107            .map_err(|_| {
6108                ProxyError::HealthCheck(format!("{} did not answer protocol probe in time", addr))
6109            })?
6110            .map_err(|e| {
6111                ProxyError::HealthCheck(format!("{} protocol probe error: {}", addr, e))
6112            })?;
6113        // 'S' (TLS available) or 'N' (not) both prove the postmaster is live and
6114        // talking the protocol; anything else means a non-PostgreSQL listener.
6115        if byte != b'S' && byte != b'N' {
6116            return Err(ProxyError::HealthCheck(format!(
6117                "{} sent unexpected probe reply {:#x}",
6118                addr, byte
6119            )));
6120        }
6121        let latency = start.elapsed().as_secs_f64() * 1000.0;
6122        Ok(latency)
6123    }
6124
6125    /// Spawn pool manager background task
6126    fn spawn_pool_manager(&self) -> tokio::task::JoinHandle<()> {
6127        // Only referenced by the pool-modes eviction/cleanup arms below.
6128        #[cfg(feature = "pool-modes")]
6129        let state = self.state.clone();
6130        let mut shutdown_rx = self.shutdown_tx.subscribe();
6131
6132        tokio::spawn(async move {
6133            let mut interval = tokio::time::interval(Self::POOL_REAP_INTERVAL);
6134
6135            loop {
6136                tokio::select! {
6137                    _ = interval.tick() => {
6138                        // Evict idle connections from pool-modes manager
6139                        #[cfg(feature = "pool-modes")]
6140                        if let Some(ref pool_manager) = state.pool_manager {
6141                            pool_manager.evict_idle().await;
6142                            tracing::trace!("Pool-modes idle eviction completed");
6143                        }
6144                        // Reap data-path idle backend connections older than the
6145                        // configured idle timeout, so a connection the backend
6146                        // would close on its own idle timeout is never handed out
6147                        // stale and idle FDs are returned to the OS.
6148                        #[cfg(feature = "pool-modes")]
6149                        if let Some(ref backend_pool) = state.backend_pool {
6150                            let ttl = std::time::Duration::from_secs(
6151                                state.live_config.load().pool_mode.idle_timeout_secs,
6152                            );
6153                            // idle_timeout_secs = 0 means "no idle TTL" (the
6154                            // PgBouncer convention). Skip reaping entirely rather
6155                            // than reaping every parked connection each cycle
6156                            // (elapsed() < ZERO is always false → retain drops
6157                            // all), which would defeat connection reuse.
6158                            let n = if ttl.is_zero() {
6159                                0
6160                            } else {
6161                                backend_pool.reap_idle(ttl)
6162                            };
6163                            if n > 0 {
6164                                tracing::debug!(
6165                                    target: "helios::pool",
6166                                    reaped = n,
6167                                    idle_remaining = backend_pool.idle_count(),
6168                                    "reaped idle backend connections (TTL)"
6169                                );
6170                            }
6171                        }
6172                    }
6173                    _ = shutdown_rx.recv() => {
6174                        // Cleanup on shutdown
6175                        #[cfg(feature = "pool-modes")]
6176                        if let Some(ref pool_manager) = state.pool_manager {
6177                            pool_manager.close_all().await;
6178                            tracing::info!("Pool-modes manager closed all connections");
6179                        }
6180                        break;
6181                    }
6182                }
6183            }
6184        })
6185    }
6186
6187    /// Spawn the edge-registry maintenance task: a periodic GC sweep that
6188    /// prunes edges not seen within the liveness window. Healthy subscribers
6189    /// are continually refreshed by their SSE heartbeat writes (registry
6190    /// `touch`), so this is a backstop that reaps wedged or dead peers whose
6191    /// heartbeats stopped succeeding — a pruned edge simply re-registers on
6192    /// its next reconnect.
6193    #[cfg(feature = "edge-proxy")]
6194    fn spawn_edge_maintenance(&self) -> tokio::task::JoinHandle<()> {
6195        let state = self.state.clone();
6196        // validate() rejects 0 when edge is enabled; .max(1) keeps the
6197        // interval panic-proof regardless of how the config was built.
6198        let gc_secs = self.config.edge.subscribe_gc_secs.max(1);
6199        let mut shutdown_rx = self.shutdown_tx.subscribe();
6200
6201        tokio::spawn(async move {
6202            let mut interval = tokio::time::interval(Duration::from_secs(gc_secs));
6203
6204            loop {
6205                tokio::select! {
6206                    _ = interval.tick() => {
6207                        let pruned = state.edge_registry.prune_stale();
6208                        if pruned > 0 {
6209                            tracing::debug!(
6210                                target: "helios::edge",
6211                                pruned,
6212                                remaining = state.edge_registry.count(),
6213                                "edge registry GC pruned stale edges"
6214                            );
6215                        }
6216                    }
6217                    _ = shutdown_rx.recv() => break,
6218                }
6219            }
6220        })
6221    }
6222
6223    /// Shutdown the server
6224    pub fn shutdown(&self) {
6225        let _ = self.shutdown_tx.send(());
6226    }
6227
6228    /// Get pool mode statistics (if pool-modes feature enabled)
6229    #[cfg(feature = "pool-modes")]
6230    pub async fn pool_mode_stats(&self) -> Option<PoolModeStatsSnapshot> {
6231        if let Some(ref pool_manager) = self.state.pool_manager {
6232            let stats = pool_manager.get_stats().await;
6233            let metrics = pool_manager.metrics().snapshot();
6234            let default_mode = pool_manager.default_mode();
6235
6236            // Calculate average lease duration across all modes
6237            let avg_lease_duration_ms = metrics
6238                .mode_stats
6239                .get(&default_mode)
6240                .map(|s| s.avg_lease_duration_ms as u64)
6241                .unwrap_or(0);
6242
6243            Some(PoolModeStatsSnapshot {
6244                mode: format!("{:?}", default_mode),
6245                total_connections: stats.total_connections,
6246                active_leases: stats.active_connections,
6247                idle_connections: stats.idle_connections,
6248                node_count: stats.node_count,
6249                acquires: metrics.acquires,
6250                releases: metrics.releases,
6251                acquire_failures: metrics.acquire_failures,
6252                acquire_timeouts: metrics.acquire_timeouts,
6253                transactions_completed: metrics.transactions_completed,
6254                statements_executed: metrics.statements_executed,
6255                avg_lease_duration_ms,
6256            })
6257        } else {
6258            None
6259        }
6260    }
6261
6262    /// Add a node to the pool manager (if pool-modes feature enabled)
6263    #[cfg(feature = "pool-modes")]
6264    pub async fn add_node_to_pool(&self, node: &NodeConfig) {
6265        if let Some(ref pool_manager) = self.state.pool_manager {
6266            let endpoint = NodeEndpoint::new(&node.host, node.port)
6267                .with_role(match node.role {
6268                    NodeRole::Primary => crate::NodeRole::Primary,
6269                    NodeRole::Standby => crate::NodeRole::Standby,
6270                    NodeRole::ReadReplica => crate::NodeRole::ReadReplica,
6271                })
6272                .with_weight(node.weight);
6273            pool_manager.add_node(&endpoint).await;
6274            tracing::info!("Added node {} to pool manager", node.address());
6275        }
6276    }
6277
6278    /// Get server metrics
6279    pub fn metrics(&self) -> ServerMetricsSnapshot {
6280        ServerMetricsSnapshot {
6281            connections_accepted: self
6282                .state
6283                .metrics
6284                .connections_accepted
6285                .load(Ordering::Relaxed),
6286            connections_closed: self
6287                .state
6288                .metrics
6289                .connections_closed
6290                .load(Ordering::Relaxed),
6291            queries_processed: self.state.metrics.queries_processed.load(Ordering::Relaxed),
6292            bytes_received: self.state.metrics.bytes_received.load(Ordering::Relaxed),
6293            bytes_sent: self.state.metrics.bytes_sent.load(Ordering::Relaxed),
6294            failovers: self.state.metrics.failovers.load(Ordering::Relaxed),
6295        }
6296    }
6297}
6298
6299/// Metrics snapshot for external consumption
6300#[derive(Debug, Clone)]
6301pub struct ServerMetricsSnapshot {
6302    pub connections_accepted: u64,
6303    pub connections_closed: u64,
6304    pub queries_processed: u64,
6305    pub bytes_received: u64,
6306    pub bytes_sent: u64,
6307    pub failovers: u64,
6308}
6309
6310/// Pool mode statistics snapshot (when pool-modes feature is enabled)
6311#[cfg(feature = "pool-modes")]
6312#[derive(Debug, Clone)]
6313pub struct PoolModeStatsSnapshot {
6314    /// Current pooling mode
6315    pub mode: String,
6316    /// Total connections across all pools
6317    pub total_connections: usize,
6318    /// Active (leased) connections
6319    pub active_leases: usize,
6320    /// Idle connections
6321    pub idle_connections: usize,
6322    /// Number of nodes in the pool
6323    pub node_count: usize,
6324    /// Total connection acquires
6325    pub acquires: u64,
6326    /// Total connection releases
6327    pub releases: u64,
6328    /// Failed acquire attempts
6329    pub acquire_failures: u64,
6330    /// Acquire timeouts
6331    pub acquire_timeouts: u64,
6332    /// Completed transactions (Transaction mode)
6333    pub transactions_completed: u64,
6334    /// Total statements executed
6335    pub statements_executed: u64,
6336    /// Average lease duration in milliseconds
6337    pub avg_lease_duration_ms: u64,
6338}
6339
6340#[cfg(test)]
6341mod tests {
6342    use super::*;
6343    use crate::config::{HealthConfig, LoadBalancerConfig, PoolConfig};
6344    #[cfg(not(feature = "wasm-plugins"))]
6345    use crate::protocol::QueryMessage;
6346
6347    fn test_config() -> ProxyConfig {
6348        let mut config = ProxyConfig::default();
6349        config.listen_address = "127.0.0.1:0".to_string();
6350        config.add_node("127.0.0.1:5432", "primary").unwrap();
6351        config
6352    }
6353
6354    #[test]
6355    fn test_server_creation() {
6356        let config = test_config();
6357        let server = ProxyServer::new(config);
6358        assert!(server.is_ok());
6359    }
6360
6361    #[test]
6362    fn is_backend_fault_excludes_client_and_slow_query_errors() {
6363        // Real backend faults — these must demote the node in-band.
6364        assert!(ProxyServer::is_backend_fault(
6365            "Backend read error: connection reset"
6366        ));
6367        assert!(ProxyServer::is_backend_fault(
6368            "Backend write error: broken pipe"
6369        ));
6370        assert!(ProxyServer::is_backend_fault("Backend write timeout"));
6371        assert!(ProxyServer::is_backend_fault(
6372            "Failed to connect to 127.0.0.1:5432: Connection refused"
6373        ));
6374        // Not backend faults — a client-side problem, or a merely slow but
6375        // healthy query, must NEVER take a backend out of rotation cluster-wide.
6376        assert!(!ProxyServer::is_backend_fault("Backend read timeout"));
6377        assert!(!ProxyServer::is_backend_fault("Client write timeout"));
6378        assert!(!ProxyServer::is_backend_fault(
6379            "Client write error: broken pipe"
6380        ));
6381        // A backend READ timeout is exempt, but a backend read ERROR is a fault.
6382        assert!(!ProxyServer::is_backend_fault("Backend read timeout"));
6383        assert!(ProxyServer::is_backend_fault(
6384            "Backend read error: timed out"
6385        ));
6386    }
6387
6388    #[test]
6389    fn test_hba_addr_matches() {
6390        use std::net::IpAddr;
6391        let v4 = |s: &str| s.parse::<IpAddr>().unwrap();
6392        // "all" matches everything
6393        assert!(ProxyServer::hba_addr_matches("all", v4("203.0.113.7")));
6394        // CIDR membership
6395        assert!(ProxyServer::hba_addr_matches("10.0.0.0/8", v4("10.1.2.3")));
6396        assert!(!ProxyServer::hba_addr_matches("10.0.0.0/8", v4("11.1.2.3")));
6397        assert!(ProxyServer::hba_addr_matches(
6398            "127.0.0.1/32",
6399            v4("127.0.0.1")
6400        ));
6401        assert!(!ProxyServer::hba_addr_matches(
6402            "127.0.0.1/32",
6403            v4("127.0.0.2")
6404        ));
6405        // bare IP exact match
6406        assert!(ProxyServer::hba_addr_matches(
6407            "192.168.1.1",
6408            v4("192.168.1.1")
6409        ));
6410        assert!(!ProxyServer::hba_addr_matches(
6411            "192.168.1.1",
6412            v4("192.168.1.2")
6413        ));
6414        // IPv6 CIDR + /0 catch-all
6415        assert!(ProxyServer::hba_addr_matches("::1/128", v4("::1")));
6416        assert!(ProxyServer::hba_addr_matches("0.0.0.0/0", v4("8.8.8.8")));
6417    }
6418
6419    #[test]
6420    fn test_hba_admits() {
6421        use crate::config::{HbaAction, HbaRule};
6422        use std::net::IpAddr;
6423        let ip: IpAddr = "10.0.0.5".parse().unwrap();
6424        // No rules -> admit all
6425        assert!(ProxyServer::hba_admits(&[], ip, "bench", "benchdb"));
6426        // Reject a specific user, allow others (default admit)
6427        let rules = vec![HbaRule {
6428            action: HbaAction::Reject,
6429            user: "bench".into(),
6430            database: "all".into(),
6431            address: "all".into(),
6432        }];
6433        assert!(!ProxyServer::hba_admits(&rules, ip, "bench", "benchdb"));
6434        assert!(ProxyServer::hba_admits(&rules, ip, "alice", "benchdb"));
6435        // First match wins: allow bench from 10/8, reject everything else
6436        let rules = vec![
6437            HbaRule {
6438                action: HbaAction::Allow,
6439                user: "bench".into(),
6440                database: "all".into(),
6441                address: "10.0.0.0/8".into(),
6442            },
6443            HbaRule {
6444                action: HbaAction::Reject,
6445                user: "all".into(),
6446                database: "all".into(),
6447                address: "all".into(),
6448            },
6449        ];
6450        assert!(ProxyServer::hba_admits(&rules, ip, "bench", "benchdb"));
6451        assert!(!ProxyServer::hba_admits(
6452            &rules,
6453            "192.168.0.1".parse().unwrap(),
6454            "bench",
6455            "benchdb"
6456        ));
6457        assert!(!ProxyServer::hba_admits(&rules, ip, "alice", "benchdb"));
6458    }
6459
6460    #[test]
6461    fn test_initial_metrics() {
6462        let config = test_config();
6463        let server = ProxyServer::new(config).unwrap();
6464        let metrics = server.metrics();
6465        assert_eq!(metrics.connections_accepted, 0);
6466        assert_eq!(metrics.queries_processed, 0);
6467    }
6468
6469    #[tokio::test]
6470    async fn test_session_creation() {
6471        let config = test_config();
6472        let server = ProxyServer::new(config).unwrap();
6473
6474        assert!(server.state.sessions.is_empty());
6475    }
6476
6477    #[tokio::test]
6478    async fn test_node_health_initialization() {
6479        let config = test_config();
6480        let server = ProxyServer::new(config).unwrap();
6481
6482        let health = server.state.health.load_full();
6483        assert!(!health.is_empty());
6484
6485        for node_health in health.values() {
6486            assert!(node_health.healthy);
6487            assert_eq!(node_health.failure_count, 0);
6488        }
6489    }
6490
6491    /// Build a minimal `ClientSession` for plugin-hook unit tests.
6492    fn make_test_session() -> Arc<ClientSession> {
6493        Arc::new(ClientSession {
6494            id: Uuid::new_v4(),
6495            client_addr: "127.0.0.1:0".parse().unwrap(),
6496            current_node: RwLock::new(None),
6497            in_transaction: std::sync::atomic::AtomicBool::new(false),
6498            copy_in_progress: std::sync::atomic::AtomicBool::new(false),
6499            tx_state: RwLock::new(TransactionState::default()),
6500            variables: RwLock::new(HashMap::new()),
6501            created_at: chrono::Utc::now(),
6502            tr_mode: crate::config::TrMode::default(),
6503            #[cfg(feature = "lag-routing")]
6504            last_write_at: RwLock::new(None),
6505            #[cfg(feature = "pool-modes")]
6506            pool_client_id: crate::pool::lease::ClientId::default(),
6507            #[cfg(feature = "wasm-plugins")]
6508            plugin_identity: RwLock::new(None),
6509            #[cfg(feature = "edge-proxy")]
6510            edge_ineligible: std::sync::atomic::AtomicBool::new(false),
6511            #[cfg(feature = "edge-proxy")]
6512            pending_edge_copy_tables: std::sync::Mutex::new(None),
6513        })
6514    }
6515
6516    /// With no plugin manager attached, `apply_route_hook` must be a
6517    /// zero-cost `None` return so the default SQL-verb routing applies.
6518    /// Verifies the feature-gated early-return path.
6519    #[tokio::test]
6520    async fn test_apply_route_hook_no_plugin_manager_returns_none() {
6521        let config = test_config();
6522        let server = ProxyServer::new(config).unwrap();
6523        let session = make_test_session();
6524
6525        let msg = QueryMessage {
6526            query: "SELECT * FROM users".to_string(),
6527        }
6528        .encode();
6529
6530        let decision = ProxyServer::apply_route_hook(&msg, &server.state, &session);
6531        assert!(matches!(decision, RouteOverride::None));
6532    }
6533
6534    /// Same invariant for the pre-query hook: without a plugin manager,
6535    /// `apply_pre_query_hook` must return the message unchanged with
6536    /// `PreQueryAction::Forward`.
6537    #[tokio::test]
6538    async fn test_apply_pre_query_hook_no_plugin_manager_forwards() {
6539        let config = test_config();
6540        let server = ProxyServer::new(config).unwrap();
6541        let session = make_test_session();
6542
6543        let original = QueryMessage {
6544            query: "SELECT 1".to_string(),
6545        }
6546        .encode();
6547        let original_bytes = original.encode().to_vec();
6548
6549        let (msg_out, action) =
6550            ProxyServer::apply_pre_query_hook(original, &server.state, &session);
6551
6552        assert!(matches!(action, PreQueryAction::Forward));
6553        // The message must survive the hook byte-for-byte when no plugins run.
6554        assert_eq!(msg_out.encode().to_vec(), original_bytes);
6555    }
6556
6557    /// Non-Query message types (e.g., extended-protocol Parse/Execute) must
6558    /// bypass the Route hook entirely regardless of plugin state, because
6559    /// we haven't wired SQL extraction for those variants yet.
6560    #[tokio::test]
6561    async fn test_apply_route_hook_skips_non_query_messages() {
6562        let config = test_config();
6563        let server = ProxyServer::new(config).unwrap();
6564        let session = make_test_session();
6565
6566        let sync_msg = Message::empty(MessageType::Sync);
6567        let decision = ProxyServer::apply_route_hook(&sync_msg, &server.state, &session);
6568        assert!(matches!(decision, RouteOverride::None));
6569    }
6570
6571    /// By default, `[plugins].enabled = false`, so `init_plugin_manager`
6572    /// short-circuits without touching the filesystem or wasmtime and
6573    /// returns `None`. The proxy starts normally whether or not a plugin
6574    /// directory exists on the host.
6575    #[cfg(feature = "wasm-plugins")]
6576    #[test]
6577    fn test_init_plugin_manager_disabled_by_default_returns_none() {
6578        let config = test_config();
6579        assert!(!config.plugins.enabled);
6580        let pm = ProxyServer::init_plugin_manager(&config.plugins);
6581        assert!(pm.is_none());
6582    }
6583
6584    /// Plugins enabled but pointing at a directory that doesn't exist
6585    /// must still initialise the manager (so new plugins can be hot-
6586    /// loaded later) and log a warning — it must NOT fail startup.
6587    #[cfg(feature = "wasm-plugins")]
6588    #[test]
6589    fn test_init_plugin_manager_missing_dir_logs_warning() {
6590        let mut config = test_config();
6591        config.plugins.enabled = true;
6592        config.plugins.plugin_dir = "/definitely/not/a/real/path".to_string();
6593
6594        // Manager is created; no panic; Some(pm) returned even with empty dir.
6595        let pm = ProxyServer::init_plugin_manager(&config.plugins);
6596        assert!(pm.is_some());
6597    }
6598
6599    /// With no plugin manager attached, `apply_authenticate_hook` is a
6600    /// zero-cost `Ok(())` that leaves session identity unset — the
6601    /// default PG auth flow applies.
6602    #[tokio::test]
6603    async fn test_apply_authenticate_hook_no_plugin_manager_defers() {
6604        let config = test_config();
6605        let server = ProxyServer::new(config).unwrap();
6606        let session = make_test_session();
6607
6608        let mut params = HashMap::new();
6609        params.insert("user".to_string(), "alice".to_string());
6610        params.insert("database".to_string(), "app".to_string());
6611
6612        let result = ProxyServer::apply_authenticate_hook(&params, &session, &server.state).await;
6613        assert!(result.is_ok());
6614
6615        // No plugin → no identity stored.
6616        #[cfg(feature = "wasm-plugins")]
6617        {
6618            let ident = session.plugin_identity.read().await;
6619            assert!(ident.is_none());
6620        }
6621    }
6622
6623    /// Cached-response synthesis round-trip: a well-formed plugin
6624    /// payload must produce concatenated wire frames in the order
6625    /// `T D D C Z`. We inspect the raw tag bytes directly because
6626    /// `MessageType::from_tag` conflates server→client DataRow (`'D'`)
6627    /// with client→server Describe (same byte) — a known quirk of the
6628    /// shared `MessageType` enum that the real proxy side-steps by
6629    /// knowing the direction at the call site.
6630    #[cfg(feature = "wasm-plugins")]
6631    #[test]
6632    fn test_synthesise_cached_response_roundtrip() {
6633        let payload = br#"{
6634            "columns": [
6635                {"name": "id",    "oid": 23},
6636                {"name": "email", "oid": 25}
6637            ],
6638            "rows": [
6639                ["1", "alice@example.com"],
6640                ["2", null]
6641            ]
6642        }"#;
6643        let reply = ProxyServer::synthesise_cached_response(payload).expect("synthesis");
6644
6645        // Walk the concatenation frame-by-frame via length prefixes.
6646        // Each PG message: tag(1) + length(4, big-endian, includes self) + payload.
6647        let mut tags = Vec::new();
6648        let mut i = 0;
6649        while i < reply.len() {
6650            let tag = reply[i];
6651            let len = u32::from_be_bytes([reply[i + 1], reply[i + 2], reply[i + 3], reply[i + 4]])
6652                as usize;
6653            tags.push(tag);
6654            i += 1 + len;
6655        }
6656        assert_eq!(i, reply.len(), "no trailing bytes");
6657        assert_eq!(tags, vec![b'T', b'D', b'D', b'C', b'Z'], "wire frame order");
6658
6659        // Spot-check the final ReadyForQuery payload is 'I' (idle).
6660        assert_eq!(*reply.last().unwrap(), b'I');
6661    }
6662
6663    /// Row width mismatch between columns and row data is rejected so
6664    /// the plugin author can't produce ambiguous wire frames.
6665    #[cfg(feature = "wasm-plugins")]
6666    #[test]
6667    fn test_synthesise_cached_response_rejects_row_width_mismatch() {
6668        let payload = br#"{
6669            "columns": [{"name": "id", "oid": 23}, {"name": "name", "oid": 25}],
6670            "rows": [["1", "alice", "extra"]]
6671        }"#;
6672        let result = ProxyServer::synthesise_cached_response(payload);
6673        assert!(matches!(result, Err(ProxyError::Protocol(_))));
6674    }
6675
6676    /// Empty payload (no columns) is rejected — a RowDescription with
6677    /// zero columns is technically valid PG but useless and likely a
6678    /// plugin bug.
6679    #[cfg(feature = "wasm-plugins")]
6680    #[test]
6681    fn test_synthesise_cached_response_rejects_empty_columns() {
6682        let payload = br#"{ "columns": [], "rows": [] }"#;
6683        let result = ProxyServer::synthesise_cached_response(payload);
6684        assert!(matches!(result, Err(ProxyError::Protocol(_))));
6685    }
6686
6687    /// Malformed JSON must return a Protocol error, not panic. The
6688    /// caller treats this as "fall back to backend."
6689    #[cfg(feature = "wasm-plugins")]
6690    #[test]
6691    fn test_synthesise_cached_response_rejects_bad_json() {
6692        let payload = b"not json at all";
6693        let result = ProxyServer::synthesise_cached_response(payload);
6694        assert!(matches!(result, Err(ProxyError::Protocol(_))));
6695    }
6696
6697    /// Denied by plugin surfaces as `ProxyError::Auth` so the existing
6698    /// error-response path in `handle_client` writes an ErrorResponse
6699    /// and closes the connection. Here we prove the error variant
6700    /// when the plugin manager is present but denies. We build a
6701    /// PluginManager with no plugins loaded — so it defers — and
6702    /// verify the Ok path. (Denial path requires an actual
6703    /// auth-plugin `.wasm`; covered by the plugin unit tests in
6704    /// `plugins::tests`.)
6705    #[cfg(feature = "wasm-plugins")]
6706    #[tokio::test]
6707    async fn test_apply_authenticate_hook_with_manager_no_plugins_defers() {
6708        use crate::plugins::{PluginManager, PluginRuntimeConfig};
6709
6710        let config = test_config();
6711        let server = ProxyServer::new(config).unwrap();
6712        let session = make_test_session();
6713
6714        // Synthesise a state with a real PluginManager but zero
6715        // registered plugins — every hook must defer.
6716        let pm = Arc::new(PluginManager::new(PluginRuntimeConfig::default()).unwrap());
6717        #[cfg(feature = "edge-proxy")]
6718        let edge_defaults = crate::edge::EdgeConfig::default();
6719        let augmented_state = Arc::new(ServerState {
6720            sessions: DashMap::new(),
6721            health: ArcSwap::from_pointee(HashMap::new()),
6722            health_write: parking_lot::Mutex::new(()),
6723            live_config: ArcSwap::from_pointee(ProxyConfig::default()),
6724            metrics: ServerMetrics::default(),
6725            cancel_map: Arc::new(DashMap::new()),
6726            cancel_order: Arc::new(parking_lot::Mutex::new(std::collections::VecDeque::new())),
6727            tls_acceptor: None,
6728            auth_file: None,
6729            mirror: None,
6730            cutover: Arc::new(ArcSwap::from_pointee(None)),
6731            lb_state: LoadBalancerState {
6732                rr_counter: AtomicU64::new(0),
6733            },
6734            #[cfg(feature = "routing-hints")]
6735            hint_parser: None,
6736            #[cfg(feature = "rate-limiting")]
6737            rate_limiter: None,
6738            #[cfg(feature = "circuit-breaker")]
6739            circuit_breaker: None,
6740            #[cfg(feature = "query-analytics")]
6741            analytics: None,
6742            #[cfg(feature = "query-cache")]
6743            query_cache: None,
6744            #[cfg(feature = "query-rewriting")]
6745            rewriter: None,
6746            #[cfg(feature = "multi-tenancy")]
6747            tenant_manager: None,
6748            #[cfg(feature = "schema-routing")]
6749            schema_analyzer: None,
6750            #[cfg(feature = "pool-modes")]
6751            pool_manager: None,
6752            #[cfg(feature = "pool-modes")]
6753            backend_pool: None,
6754            plugin_manager: Some(pm),
6755            #[cfg(feature = "ha-tr")]
6756            transaction_journal: Arc::new(crate::transaction_journal::TransactionJournal::new()),
6757            #[cfg(feature = "anomaly-detection")]
6758            anomaly_detector: Arc::new(crate::anomaly::AnomalyDetector::new(
6759                crate::anomaly::AnomalyConfig::default(),
6760            )),
6761            #[cfg(feature = "edge-proxy")]
6762            edge_cache: Arc::new(crate::edge::EdgeCache::new(
6763                edge_defaults.max_entries.max(1),
6764            )),
6765            #[cfg(feature = "edge-proxy")]
6766            edge_registry: Arc::new(crate::edge::EdgeRegistry::new(
6767                edge_defaults.max_edges,
6768                std::time::Duration::from_secs(edge_defaults.liveness_window_secs),
6769            )),
6770        });
6771
6772        let mut params = HashMap::new();
6773        params.insert("user".to_string(), "alice".to_string());
6774
6775        let result =
6776            ProxyServer::apply_authenticate_hook(&params, &session, &augmented_state).await;
6777        assert!(result.is_ok());
6778        let ident = session.plugin_identity.read().await;
6779        assert!(ident.is_none());
6780        // Unused bindings for the sync-state build path.
6781        let _ = server;
6782    }
6783
6784    // ---- Batch F.4: prepared-statement tracking across backend switches ----
6785
6786    fn cstr(s: &str) -> Vec<u8> {
6787        let mut v = s.as_bytes().to_vec();
6788        v.push(0);
6789        v
6790    }
6791
6792    #[test]
6793    fn parse_stmt_name_extracts_named_and_unnamed() {
6794        // Parse payload = stmt-name cstring + query cstring + int16 nparams.
6795        let mut named = cstr("ps1");
6796        named.extend_from_slice(&cstr("SELECT 1"));
6797        named.extend_from_slice(&[0, 0]);
6798        assert_eq!(ProxyServer::parse_stmt_name(&named), "ps1");
6799
6800        let mut unnamed = cstr("");
6801        unnamed.extend_from_slice(&cstr("SELECT 1"));
6802        unnamed.extend_from_slice(&[0, 0]);
6803        assert_eq!(ProxyServer::parse_stmt_name(&unnamed), "");
6804    }
6805
6806    #[test]
6807    fn bind_stmt_ref_reads_second_cstring() {
6808        // Bind payload = portal cstring + statement cstring + ...
6809        let mut named = cstr("portal_a");
6810        named.extend_from_slice(&cstr("ps1"));
6811        named.extend_from_slice(&[0, 0]); // 0 param-format codes, 0 params
6812        assert_eq!(ProxyServer::bind_stmt_ref(&named), Some("ps1"));
6813
6814        // Unnamed statement (empty second cstring) is not tracked.
6815        let mut unnamed = cstr("");
6816        unnamed.extend_from_slice(&cstr(""));
6817        assert_eq!(ProxyServer::bind_stmt_ref(&unnamed), None);
6818    }
6819
6820    #[test]
6821    fn stmt_kind_name_only_matches_statement_kind() {
6822        // Describe/Close 'S' (statement) carries a trackable name.
6823        let mut stmt = vec![b'S'];
6824        stmt.extend_from_slice(&cstr("ps1"));
6825        assert_eq!(ProxyServer::stmt_kind_name(&stmt), Some("ps1"));
6826
6827        // 'P' (portal) is not a statement reference.
6828        let mut portal = vec![b'P'];
6829        portal.extend_from_slice(&cstr("portal_a"));
6830        assert_eq!(ProxyServer::stmt_kind_name(&portal), None);
6831
6832        // Statement-kind but unnamed -> nothing to track.
6833        let mut empty = vec![b'S'];
6834        empty.extend_from_slice(&cstr(""));
6835        assert_eq!(ProxyServer::stmt_kind_name(&empty), None);
6836    }
6837
6838    #[tokio::test]
6839    async fn read_one_frame_type_consumes_full_frame() {
6840        // ParseComplete '1' with empty body, followed by a second frame to
6841        // prove only the first frame is consumed.
6842        let (mut a, mut b) = tokio::io::duplex(64);
6843        // frame 1: '1' + len(4) + no body; frame 2: 'Z' + len(5) + 'I'.
6844        let bytes = [b'1', 0, 0, 0, 4, b'Z', 0, 0, 0, 5, b'I'];
6845        b.write_all(&bytes).await.unwrap();
6846        let t = ProxyServer::read_one_frame_type(&mut a).await.unwrap();
6847        assert_eq!(t, b'1');
6848        // The next frame's type byte is still readable -> we stopped cleanly.
6849        let t2 = ProxyServer::read_one_frame_type(&mut a).await.unwrap();
6850        assert_eq!(t2, b'Z');
6851    }
6852
6853    #[tokio::test]
6854    async fn reprepare_statement_accepts_parse_complete_and_rejects_error() {
6855        // Backend answers ParseComplete -> Ok.
6856        let (mut client, mut backend) = tokio::io::duplex(64);
6857        backend.write_all(&[b'1', 0, 0, 0, 4]).await.unwrap();
6858        let parse = {
6859            let mut p = vec![b'P', 0, 0, 0, 0];
6860            p.extend_from_slice(&cstr("ps1"));
6861            p.extend_from_slice(&cstr("SELECT 1"));
6862            p.extend_from_slice(&[0, 0]);
6863            p
6864        };
6865        assert!(ProxyServer::reprepare_statement(&mut client, &parse)
6866            .await
6867            .is_ok());
6868
6869        // Backend answers ErrorResponse -> Err.
6870        let (mut client2, mut backend2) = tokio::io::duplex(64);
6871        backend2.write_all(&[b'E', 0, 0, 0, 4]).await.unwrap();
6872        assert!(ProxyServer::reprepare_statement(&mut client2, &parse)
6873            .await
6874            .is_err());
6875    }
6876
6877    // ---- routing-hints: SQL-comment hint → RouteOverride mapping ----
6878
6879    #[cfg(feature = "routing-hints")]
6880    mod routing_hints {
6881        use super::*;
6882        use crate::routing::HintParser;
6883
6884        fn over(sql: &str) -> RouteOverride {
6885            let hints = HintParser::new().parse(sql);
6886            ProxyServer::hint_to_override(&hints)
6887        }
6888
6889        #[test]
6890        fn route_primary_maps_to_primary() {
6891            assert!(matches!(
6892                over("/*helios:route=primary*/ SELECT 1"),
6893                RouteOverride::Primary
6894            ));
6895        }
6896
6897        #[test]
6898        fn read_tier_targets_map_to_standby() {
6899            for t in ["standby", "sync", "semisync", "async", "local"] {
6900                assert!(
6901                    matches!(
6902                        over(&format!("/*helios:route={t}*/ SELECT 1")),
6903                        RouteOverride::Standby
6904                    ),
6905                    "route={t} should map to Standby"
6906                );
6907            }
6908        }
6909
6910        #[test]
6911        fn any_and_vector_impose_no_constraint() {
6912            assert!(matches!(
6913                over("/*helios:route=any*/ SELECT 1"),
6914                RouteOverride::None
6915            ));
6916            assert!(matches!(
6917                over("/*helios:route=vector*/ SELECT 1"),
6918                RouteOverride::None
6919            ));
6920        }
6921
6922        #[test]
6923        fn node_hint_maps_to_node_and_wins_over_route() {
6924            // node= beats route= (precedence).
6925            match over("/*helios:node=pg-standby,route=primary*/ SELECT 1") {
6926                RouteOverride::Node(n) => assert_eq!(n, "pg-standby"),
6927                other => panic!("expected Node, got {other:?}"),
6928            }
6929        }
6930
6931        #[test]
6932        fn consistency_strong_forces_primary() {
6933            assert!(matches!(
6934                over("/*helios:consistency=strong*/ SELECT 1"),
6935                RouteOverride::Primary
6936            ));
6937        }
6938
6939        #[test]
6940        fn no_hint_yields_none() {
6941            assert!(matches!(over("SELECT 1"), RouteOverride::None));
6942        }
6943
6944        // The core correctness fix: a leading hint comment must NOT hide the
6945        // verb from write-detection. Raw classification misfires; classifying
6946        // on the stripped SQL is correct.
6947        #[test]
6948        fn write_verb_classified_after_strip() {
6949            let parser = HintParser::new();
6950            let raw = "/*helios:route=primary*/ INSERT INTO t VALUES (1)";
6951            // Raw (unstripped) wrongly looks like a read because it starts
6952            // with the comment.
6953            assert!(!ProxyServer::is_write_query(raw));
6954            // Stripped is correctly a write.
6955            assert!(ProxyServer::is_write_query(&parser.strip(raw)));
6956        }
6957
6958        #[test]
6959        fn strip_removes_hint_comment() {
6960            let parser = HintParser::new();
6961            assert_eq!(
6962                parser.strip("/*helios:route=standby*/ SELECT 42"),
6963                "SELECT 42"
6964            );
6965        }
6966    }
6967
6968    // ---- rate-limiting: the burst-then-deny contract the gate relies on ----
6969
6970    #[cfg(feature = "rate-limiting")]
6971    mod rate_limiting {
6972        use crate::rate_limit::{LimiterKey, RateLimitConfig, RateLimitResult, RateLimiter};
6973
6974        #[test]
6975        fn burst_allows_then_denies() {
6976            // Mirror the wiring's config conversion: tiny bucket, reject on
6977            // exceed (the engine default).
6978            let cfg = RateLimitConfig {
6979                enabled: true,
6980                default_qps: 1,
6981                default_burst: 2,
6982                ..Default::default()
6983            };
6984            let limiter = RateLimiter::new(cfg);
6985            let key = LimiterKey::User("u".to_string());
6986
6987            // The first `burst` checks are admitted.
6988            assert!(matches!(limiter.check(&key, 1), RateLimitResult::Allowed));
6989            assert!(matches!(limiter.check(&key, 1), RateLimitResult::Allowed));
6990
6991            // Rapid over-burst checks must produce at least one hard denial.
6992            let mut denied = false;
6993            for _ in 0..5 {
6994                if matches!(limiter.check(&key, 1), RateLimitResult::Denied(_)) {
6995                    denied = true;
6996                }
6997            }
6998            assert!(denied, "over-burst checks must yield a Denied verdict");
6999        }
7000
7001        #[test]
7002        fn distinct_keys_have_independent_buckets() {
7003            let cfg = RateLimitConfig {
7004                enabled: true,
7005                default_qps: 1,
7006                default_burst: 1,
7007                ..Default::default()
7008            };
7009            let limiter = RateLimiter::new(cfg);
7010            // Each user gets its own bucket: both first checks are admitted.
7011            assert!(matches!(
7012                limiter.check(&LimiterKey::User("a".to_string()), 1),
7013                RateLimitResult::Allowed
7014            ));
7015            assert!(matches!(
7016                limiter.check(&LimiterKey::User("b".to_string()), 1),
7017                RateLimitResult::Allowed
7018            ));
7019        }
7020    }
7021
7022    // ---- circuit-breaker: open-after-threshold contract the gate relies on ----
7023
7024    #[cfg(feature = "circuit-breaker")]
7025    mod circuit_breaker {
7026        use crate::circuit_breaker::{
7027            CircuitBreakerConfig, CircuitBreakerManager, CircuitState, ManagerConfig,
7028        };
7029        use std::time::Duration;
7030
7031        fn mgr(threshold: u32) -> CircuitBreakerManager {
7032            let cfg = CircuitBreakerConfig {
7033                failure_threshold: threshold,
7034                cooldown: Duration::from_secs(10),
7035                ..Default::default()
7036            };
7037            CircuitBreakerManager::new(ManagerConfig::new(cfg))
7038        }
7039
7040        #[test]
7041        fn opens_after_threshold_failures() {
7042            let m = mgr(3);
7043            let b = m.get_breaker("n1");
7044            assert_eq!(b.get_state(), CircuitState::Closed);
7045            b.record_failure("boom");
7046            b.record_failure("boom");
7047            // Under threshold: still serving.
7048            assert_eq!(b.get_state(), CircuitState::Closed);
7049            // Threshold reached: tripped open.
7050            b.record_failure("boom");
7051            assert_eq!(b.get_state(), CircuitState::Open);
7052        }
7053
7054        #[test]
7055        fn healthy_node_stays_closed() {
7056            let m = mgr(3);
7057            let b = m.get_breaker("n2");
7058            b.record_success();
7059            b.record_success();
7060            assert_eq!(b.get_state(), CircuitState::Closed);
7061        }
7062    }
7063
7064    // ---- query-analytics: record + literal-collapsing normalizer ----
7065
7066    #[cfg(feature = "query-analytics")]
7067    mod query_analytics {
7068        use crate::analytics::{AnalyticsConfig, OrderBy, QueryAnalytics, QueryExecution};
7069        use std::time::Duration;
7070
7071        #[test]
7072        fn records_and_collapses_literals() {
7073            let a = QueryAnalytics::new(AnalyticsConfig::default());
7074            for n in [1, 2, 3] {
7075                a.record(QueryExecution::new(
7076                    format!("select {n}"),
7077                    Duration::from_millis(1),
7078                ));
7079            }
7080            let top = a.top_queries(OrderBy::Calls, 10);
7081            assert!(!top.is_empty(), "no fingerprints recorded");
7082            // The three literal variants collapse to one fingerprint (3 calls).
7083            assert!(
7084                top.iter().any(|s| s.calls >= 3),
7085                "literals did not collapse: {:?}",
7086                top.iter()
7087                    .map(|s| (s.normalized.clone(), s.calls))
7088                    .collect::<Vec<_>>()
7089            );
7090        }
7091    }
7092
7093    // ---- lag-routing: read-your-writes window + lag-exclusion decisions ----
7094
7095    #[cfg(feature = "lag-routing")]
7096    mod lag_routing {
7097        use super::ProxyServer;
7098
7099        #[test]
7100        fn ryw_pins_recent_write() {
7101            // A write "now" falls inside a 1s window -> pin to primary.
7102            assert!(ProxyServer::ryw_pins_primary(
7103                Some(std::time::Instant::now()),
7104                1000
7105            ));
7106        }
7107
7108        #[test]
7109        fn ryw_releases_old_write() {
7110            let old = std::time::Instant::now()
7111                .checked_sub(std::time::Duration::from_secs(10))
7112                .unwrap();
7113            assert!(!ProxyServer::ryw_pins_primary(Some(old), 1000));
7114        }
7115
7116        #[test]
7117        fn ryw_no_write_or_disabled() {
7118            assert!(!ProxyServer::ryw_pins_primary(None, 1000));
7119            // window=0 disables read-your-writes entirely.
7120            assert!(!ProxyServer::ryw_pins_primary(
7121                Some(std::time::Instant::now()),
7122                0
7123            ));
7124        }
7125
7126        #[test]
7127        fn lag_exclusion_thresholds() {
7128            // max=0 disables exclusion.
7129            assert!(!ProxyServer::lag_excludes_standby(Some(999_999), 0));
7130            // unknown lag never excludes.
7131            assert!(!ProxyServer::lag_excludes_standby(None, 1000));
7132            // within ceiling stays in rotation.
7133            assert!(!ProxyServer::lag_excludes_standby(Some(500), 1000));
7134            // beyond ceiling is dropped.
7135            assert!(ProxyServer::lag_excludes_standby(Some(2000), 1000));
7136        }
7137    }
7138
7139    // ---- query-cache: which read SQL is safe to cache ----
7140
7141    #[cfg(feature = "query-cache")]
7142    mod query_cache {
7143        use super::ProxyServer;
7144
7145        #[test]
7146        fn plain_selects_are_cacheable() {
7147            assert!(ProxyServer::is_cacheable_read_sql("select v from t"));
7148            assert!(ProxyServer::is_cacheable_read_sql(
7149                "  SELECT a, b FROM users WHERE id = 5"
7150            ));
7151        }
7152
7153        #[test]
7154        fn writes_and_non_selects_are_not_cacheable() {
7155            assert!(!ProxyServer::is_cacheable_read_sql(
7156                "insert into t values (1)"
7157            ));
7158            assert!(!ProxyServer::is_cacheable_read_sql("update t set v = 1"));
7159            assert!(!ProxyServer::is_cacheable_read_sql("show search_path"));
7160        }
7161
7162        #[test]
7163        fn locking_and_volatile_selects_are_not_cacheable() {
7164            assert!(!ProxyServer::is_cacheable_read_sql(
7165                "select * from t for update"
7166            ));
7167            assert!(!ProxyServer::is_cacheable_read_sql("select now()"));
7168            assert!(!ProxyServer::is_cacheable_read_sql("select random()"));
7169            assert!(!ProxyServer::is_cacheable_read_sql("select nextval('s')"));
7170            // set_config mutates GUCs + emits ParameterStatus — replaying
7171            // from cache would suppress the side effect.
7172            assert!(!ProxyServer::is_cacheable_read_sql(
7173                "select set_config('timezone', 'UTC', false) from t"
7174            ));
7175        }
7176
7177        #[test]
7178        fn multi_statement_strings_are_not_cacheable() {
7179            // Replaying `SELECT ...; UPDATE ...` would fabricate the
7180            // UPDATE's CommandComplete while executing nothing.
7181            assert!(!ProxyServer::is_cacheable_read_sql(
7182                "select v from t; update t set v = 1"
7183            ));
7184            assert!(!ProxyServer::is_cacheable_read_sql("select 1; select 2"));
7185            // A single trailing semicolon stays cacheable.
7186            assert!(ProxyServer::is_cacheable_read_sql("select v from t;"));
7187            assert!(ProxyServer::is_cacheable_read_sql("SELECT v FROM t ; "));
7188        }
7189
7190        #[test]
7191        fn literal_semicolon_or_into_over_rejects_by_design() {
7192            // G7 (accepted, safe-direction): the multi-statement and SELECT INTO
7193            // guards scan the RAW text, so a ';' or the word "into" inside a
7194            // string literal disqualifies an otherwise-cacheable SELECT. This
7195            // over-rejection is DELIBERATE and hit-rate-only — the raw scan is
7196            // the sole defense against multi-statement replay fabrication (a
7197            // literal-stripping pre-pass would misjudge `'x\'; UPDATE ...'` under
7198            // standard_conforming_strings and reopen that hole), and a real
7199            // SELECT INTO is protocol-indistinguishable from a plain SELECT.
7200            assert!(!ProxyServer::is_cacheable_read_sql(
7201                "select v from t where url = 'a;b=c'"
7202            ));
7203            assert!(!ProxyServer::is_cacheable_read_sql(
7204                "select v from t where body like '%go into space%'"
7205            ));
7206            // A real SELECT INTO (it creates a table) must never be cached.
7207            assert!(!ProxyServer::is_cacheable_read_sql(
7208                "select * into snapshot from t"
7209            ));
7210        }
7211
7212        #[test]
7213        fn select_into_is_not_cacheable() {
7214            // SELECT ... INTO creates a table (CREATE TABLE AS synonym);
7215            // a cache replay would silently skip the DDL.
7216            assert!(!ProxyServer::is_cacheable_read_sql(
7217                "select * into report_tmp from src"
7218            ));
7219            // Word-boundary: newline/tab-delimited INTO is caught too.
7220            assert!(!ProxyServer::is_cacheable_read_sql(
7221                "SELECT *\nINTO report_tmp\nFROM src"
7222            ));
7223            // ...but an identifier merely containing "into" is not.
7224            assert!(ProxyServer::is_cacheable_read_sql(
7225                "select into_total from t"
7226            ));
7227        }
7228    }
7229
7230    // ---- edge-proxy: write-invalidation classifiers ----
7231
7232    #[cfg(feature = "edge-proxy")]
7233    mod edge_proxy {
7234        use super::ProxyServer;
7235        use std::collections::HashMap;
7236
7237        #[test]
7238        fn bare_txn_control_is_exempt_from_invalidation() {
7239            // F10: BEGIN/START/SAVEPOINT/RELEASE/ROLLBACK change no rows —
7240            // an ORM's txn-per-request must not full-flush the fleet twice
7241            // per request.
7242            for sql in [
7243                "BEGIN",
7244                "begin;",
7245                "START TRANSACTION",
7246                "SAVEPOINT sp1",
7247                "RELEASE SAVEPOINT sp1",
7248                "ROLLBACK",
7249                "ROLLBACK TO SAVEPOINT sp1;",
7250            ] {
7251                assert!(
7252                    !ProxyServer::edge_write_needs_invalidation(true, sql),
7253                    "{sql:?} must be exempt"
7254                );
7255            }
7256            // COMMIT keeps its conservative flush (closes the in-txn
7257            // write visibility window), and SET stays (GUC mitigation).
7258            assert!(ProxyServer::edge_write_needs_invalidation(true, "COMMIT"));
7259            assert!(ProxyServer::edge_write_needs_invalidation(
7260                true,
7261                "SET search_path TO tenant_b"
7262            ));
7263        }
7264
7265        #[test]
7266        fn compound_txn_strings_still_invalidate() {
7267            // A multi-statement string may hide a write behind a
7268            // txn-control or SELECT lead — never exempt it.
7269            assert!(ProxyServer::edge_write_needs_invalidation(
7270                true,
7271                "BEGIN; UPDATE t SET v = 1; COMMIT"
7272            ));
7273            // SELECT-leading batch with a trailing write classifies
7274            // is_write=false, but the interior `;` forces invalidation.
7275            assert!(ProxyServer::edge_write_needs_invalidation(
7276                false,
7277                "SELECT v FROM t; UPDATE t SET v = 1"
7278            ));
7279            // A plain single SELECT does not invalidate.
7280            assert!(!ProxyServer::edge_write_needs_invalidation(
7281                false,
7282                "SELECT v FROM t"
7283            ));
7284        }
7285
7286        #[test]
7287        fn copy_from_counts_as_write() {
7288            assert!(ProxyServer::edge_write_needs_invalidation(
7289                false,
7290                "COPY t FROM STDIN"
7291            ));
7292            assert!(ProxyServer::is_edge_copy_write_sql("copy t from stdin"));
7293            // COPY TO exports rows — a read.
7294            assert!(!ProxyServer::edge_write_needs_invalidation(
7295                false,
7296                "COPY t TO STDOUT"
7297            ));
7298        }
7299
7300        #[test]
7301        fn procedural_and_txn_end_trigger_invalidation() {
7302            // G3: SQL-level EXECUTE/CALL/DO are opaque writes — the simple path
7303            // must invalidate and the classifier flags them (the extended path
7304            // memoizes them as the empty-set wildcard via the same predicate).
7305            for sql in [
7306                "EXECUTE ins(1)",
7307                "execute ins(1)",
7308                "CALL do_write()",
7309                "DO $$ BEGIN PERFORM 1; END $$",
7310            ] {
7311                assert!(
7312                    ProxyServer::is_edge_procedural_sql(sql),
7313                    "{sql:?} is procedural"
7314                );
7315                assert!(
7316                    ProxyServer::edge_write_needs_invalidation(false, sql),
7317                    "{sql:?} must invalidate on the simple path"
7318                );
7319            }
7320            for sql in [
7321                "INSERT INTO t VALUES (1)",
7322                "SELECT 1 FROM t",
7323                "UPDATE t SET v=1",
7324            ] {
7325                assert!(
7326                    !ProxyServer::is_edge_procedural_sql(sql),
7327                    "{sql:?} is not procedural"
7328                );
7329            }
7330
7331            // G2: COMMIT and its END synonym both trigger the wildcard flush
7332            // (closing the commit-visibility window); the openers do not.
7333            for sql in [
7334                "COMMIT",
7335                "commit work",
7336                "COMMIT PREPARED 'x'",
7337                "END",
7338                "END TRANSACTION",
7339                "end;",
7340            ] {
7341                assert!(ProxyServer::is_edge_txn_end_sql(sql), "{sql:?} ends a txn");
7342                assert!(
7343                    ProxyServer::edge_write_needs_invalidation(false, sql),
7344                    "{sql:?} must invalidate (commit-visibility window)"
7345                );
7346            }
7347            for sql in ["BEGIN", "START TRANSACTION", "SAVEPOINT s1", "ROLLBACK"] {
7348                assert!(
7349                    !ProxyServer::is_edge_txn_end_sql(sql),
7350                    "{sql:?} is not a txn-end"
7351                );
7352                assert!(
7353                    !ProxyServer::edge_write_needs_invalidation(false, sql),
7354                    "{sql:?} must stay exempt on the simple path"
7355                );
7356            }
7357        }
7358
7359        #[test]
7360        fn edge_meta_prunable_protects_reparsed_names() {
7361            // G1: at a Sync, a Closed name NOT re-Parsed this batch is prunable;
7362            // a name Closed then re-Parsed (Npgsql statement replacement) must be
7363            // RETAINED so its fresh DML metadata survives to invalidate.
7364            let closes = vec!["S1".to_string(), "S2".to_string()];
7365            let none: Vec<String> = vec![];
7366            assert_eq!(
7367                ProxyServer::edge_meta_prunable(&closes, &none),
7368                vec!["S1", "S2"]
7369            );
7370            // S1 re-Parsed in the same batch → excluded from pruning.
7371            assert_eq!(
7372                ProxyServer::edge_meta_prunable(&closes, &["S1".to_string()]),
7373                vec!["S2"]
7374            );
7375            // Every closed name re-Parsed → nothing pruned (all meta kept fresh).
7376            assert!(ProxyServer::edge_meta_prunable(&closes, &closes).is_empty());
7377        }
7378
7379        #[test]
7380        fn edge_dml_classifier_covers_dml_not_txn_control() {
7381            for sql in [
7382                "INSERT INTO t VALUES (1)",
7383                "update t set v = 1",
7384                "DELETE FROM t WHERE id = 1",
7385                "MERGE INTO t USING s ON t.id = s.id WHEN MATCHED THEN UPDATE SET v = s.v",
7386                "TRUNCATE t",
7387                "ALTER TABLE t ADD COLUMN c int",
7388                "COPY t FROM STDIN",
7389                "WITH del AS (DELETE FROM t RETURNING id) SELECT * FROM del",
7390            ] {
7391                assert!(ProxyServer::is_edge_dml_sql(sql), "{sql:?} is DML");
7392            }
7393            // Txn control / reads / plain CTE reads: NOT invalidation
7394            // triggers (BEGIN/COMMIT here would full-flush per txn).
7395            for sql in [
7396                "BEGIN",
7397                "COMMIT",
7398                "ROLLBACK",
7399                "SET search_path TO x",
7400                "SELECT v FROM t",
7401                "WITH x AS (SELECT 1) SELECT * FROM x",
7402                "COPY t TO STDOUT",
7403            ] {
7404                assert!(!ProxyServer::is_edge_dml_sql(sql), "{sql:?} is not DML");
7405            }
7406        }
7407
7408        #[test]
7409        fn extended_batch_tables_union_and_wildcard() {
7410            let mut named: HashMap<String, Option<Vec<String>>> = HashMap::new();
7411            named.insert("ins".into(), Some(vec!["orders".into()]));
7412            named.insert("upd".into(), Some(vec!["users".into()]));
7413            named.insert("sel".into(), None);
7414            named.insert("weird".into(), Some(vec![])); // unattributable DML
7415            let unnamed: Option<Vec<String>> = Some(vec!["events".into()]);
7416
7417            // Read-only batch: no invalidation.
7418            assert_eq!(
7419                ProxyServer::edge_extended_batch_tables(
7420                    &["sel".to_string()],
7421                    false,
7422                    &named,
7423                    &unnamed
7424                ),
7425                None
7426            );
7427            // DML batch: union of referenced statements' tables.
7428            let t = ProxyServer::edge_extended_batch_tables(
7429                &["ins".to_string(), "upd".to_string(), "ins".to_string()],
7430                false,
7431                &named,
7432                &unnamed,
7433            )
7434            .expect("dml");
7435            assert_eq!(t, vec!["orders".to_string(), "users".to_string()]);
7436            // Unnamed execution folds in.
7437            let t = ProxyServer::edge_extended_batch_tables(&[], true, &named, &unnamed)
7438                .expect("unnamed dml");
7439            assert_eq!(t, vec!["events".to_string()]);
7440            // Any unattributable DML → wildcard (invalidate everything).
7441            let t = ProxyServer::edge_extended_batch_tables(
7442                &["ins".to_string(), "weird".to_string()],
7443                false,
7444                &named,
7445                &unnamed,
7446            )
7447            .expect("dml");
7448            assert!(t.is_empty(), "wildcard invalidation");
7449            // Unknown names (never Parse'd — backend errors anyway) are
7450            // treated as non-DML.
7451            assert_eq!(
7452                ProxyServer::edge_extended_batch_tables(
7453                    &["ghost".to_string()],
7454                    false,
7455                    &named,
7456                    &None
7457                ),
7458                None
7459            );
7460        }
7461    }
7462
7463    /// F12: async backend frames (NotificationResponse 'A', NoticeResponse
7464    /// 'N', ParameterStatus 'S') captured inside a response window must
7465    /// suppress the store (`cacheable = false`) while still being forwarded
7466    /// byte-for-byte — a cached LISTEN/NOTIFY payload would replay to every
7467    /// later hitter cross-session.
7468    #[cfg(any(feature = "query-cache", feature = "edge-proxy"))]
7469    #[tokio::test]
7470    async fn capture_excludes_async_frames_from_cacheable() {
7471        use crate::client_tls::ClientStream;
7472        use tokio::io::AsyncReadExt;
7473        use tokio::io::AsyncWriteExt as _;
7474        use tokio::net::{TcpListener, TcpStream};
7475
7476        async fn pair() -> (TcpStream, TcpStream) {
7477            let l = TcpListener::bind("127.0.0.1:0").await.unwrap();
7478            let addr = l.local_addr().unwrap();
7479            let (accepted, connected) = tokio::join!(l.accept(), TcpStream::connect(addr));
7480            (accepted.unwrap().0, connected.unwrap())
7481        }
7482
7483        fn frame(mtype: u8, body: &[u8]) -> Vec<u8> {
7484            let mut v = vec![mtype];
7485            v.extend_from_slice(&((body.len() + 4) as u32).to_be_bytes());
7486            v.extend_from_slice(body);
7487            v
7488        }
7489
7490        let clean: Vec<u8> = [
7491            frame(b'T', b"rowdesc"),
7492            frame(b'D', b"row"),
7493            frame(b'C', b"SELECT 1\0"),
7494            frame(b'Z', b"I"),
7495        ]
7496        .concat();
7497        let with_notify: Vec<u8> = [
7498            frame(b'T', b"rowdesc"),
7499            frame(b'A', b"\x00\x00\x30\x39chan\0payload\0"),
7500            frame(b'D', b"row"),
7501            frame(b'C', b"SELECT 1\0"),
7502            frame(b'Z', b"I"),
7503        ]
7504        .concat();
7505        let with_param_status: Vec<u8> = [
7506            frame(b'D', b"row"),
7507            frame(b'C', b"SELECT 1\0"),
7508            frame(b'S', b"TimeZone\0UTC\0"),
7509            frame(b'Z', b"I"),
7510        ]
7511        .concat();
7512
7513        for (bytes, want_cacheable) in [
7514            (clean, true),
7515            (with_notify, false),
7516            (with_param_status, false),
7517        ] {
7518            let (mut backend, mut backend_peer) = pair().await;
7519            let (client_raw, mut client_peer) = pair().await;
7520            let mut client = ClientStream::Plain(client_raw);
7521            let session = make_test_session();
7522
7523            backend_peer.write_all(&bytes).await.unwrap();
7524            backend_peer.flush().await.unwrap();
7525
7526            let (sent, captured, cacheable, _rows) =
7527                ProxyServer::stream_until_ready_capture(&mut client, &mut backend, &session)
7528                    .await
7529                    .expect("capture ok");
7530            assert_eq!(cacheable, want_cacheable, "cacheable flag");
7531            assert_eq!(sent as usize, bytes.len());
7532            assert_eq!(captured, bytes, "capture is byte-exact");
7533
7534            // Every frame — async ones included — was forwarded to the
7535            // live client.
7536            let mut got = vec![0u8; bytes.len()];
7537            client_peer.read_exact(&mut got).await.unwrap();
7538            assert_eq!(got, bytes, "forwarding must not be filtered");
7539        }
7540    }
7541
7542    // ---- query-rewriting: the rules-engine rewrite contract ----
7543
7544    #[cfg(feature = "query-rewriting")]
7545    mod query_rewriting {
7546        use crate::rewriter::{
7547            QueryPattern, QueryRewriter, RewriteRule, RewriterConfig, Transformation,
7548        };
7549
7550        fn rw_with_table_replace() -> QueryRewriter {
7551            let rw = QueryRewriter::new(RewriterConfig {
7552                enabled: true,
7553                ..Default::default()
7554            });
7555            rw.add_rule(
7556                RewriteRule::build("t")
7557                    .pattern(QueryPattern::Table("a".to_string()))
7558                    .transform(Transformation::ReplaceTable {
7559                        from: "a".to_string(),
7560                        to: "b".to_string(),
7561                    })
7562                    .build(),
7563            );
7564            rw
7565        }
7566
7567        #[test]
7568        fn matching_query_is_rewritten() {
7569            let res = rw_with_table_replace().rewrite("select * from a").unwrap();
7570            assert!(res.was_rewritten(), "rule did not fire");
7571            assert!(res.query().contains('b'), "rewritten: {}", res.query());
7572            assert!(
7573                !res.query().contains("from a"),
7574                "still references a: {}",
7575                res.query()
7576            );
7577        }
7578
7579        #[test]
7580        fn unmatched_query_is_unchanged() {
7581            let res = rw_with_table_replace()
7582                .rewrite("select * from other")
7583                .unwrap();
7584            assert!(!res.was_rewritten());
7585            assert_eq!(res.query(), "select * from other");
7586        }
7587    }
7588
7589    // ---- multi-tenancy: row-filter injection per tenant ----
7590
7591    #[cfg(feature = "multi-tenancy")]
7592    mod multi_tenancy {
7593        use crate::multi_tenancy::{
7594            IdentificationMethod, IsolationStrategy, MultiTenancyConfig, TenantConfig, TenantId,
7595            TenantManager, TenantManagerBuilder, TenantQueryTransformer,
7596        };
7597
7598        fn manager() -> TenantManager {
7599            let transformer = TenantQueryTransformer::new().register_tables(&["t"], "tid");
7600            let tm = TenantManagerBuilder::new()
7601                .config(MultiTenancyConfig {
7602                    enabled: true,
7603                    identification: IdentificationMethod::Header {
7604                        header_name: "application_name".to_string(),
7605                    },
7606                    ..Default::default()
7607                })
7608                .query_transformer(transformer)
7609                .build();
7610            tm.register_tenant(TenantConfig::new(
7611                TenantId::new("acme"),
7612                IsolationStrategy::row("public", "tid"),
7613            ));
7614            tm
7615        }
7616
7617        #[test]
7618        fn tenant_table_gets_filter() {
7619            let res = manager().transform_query("select * from t", &TenantId::new("acme"));
7620            assert!(res.transformed, "expected a tenant filter to be injected");
7621            let q = res.query.to_lowercase();
7622            assert!(
7623                q.contains("tid") && q.contains("acme"),
7624                "filter missing: {}",
7625                res.query
7626            );
7627        }
7628
7629        #[test]
7630        fn non_tenant_table_passes_through() {
7631            let res = manager().transform_query("select * from other", &TenantId::new("acme"));
7632            assert!(!res.transformed);
7633        }
7634    }
7635
7636    // ---- ha-tr: the journal records statements the replay engine reads ----
7637
7638    #[cfg(feature = "ha-tr")]
7639    mod ha_tr {
7640        use crate::transaction_journal::TransactionJournal;
7641        use crate::NodeId;
7642
7643        #[tokio::test]
7644        async fn journal_records_and_windows_a_statement() {
7645            let j = TransactionJournal::new();
7646            let from = chrono::Utc::now() - chrono::Duration::seconds(60);
7647            let tx = uuid::Uuid::new_v4();
7648            j.begin_transaction(tx, uuid::Uuid::new_v4(), NodeId::new(), 0)
7649                .await
7650                .unwrap();
7651            j.log_statement(
7652                tx,
7653                "insert into t values (1)".to_string(),
7654                Vec::new(),
7655                None,
7656                None,
7657                0,
7658            )
7659            .await
7660            .unwrap();
7661            let to = chrono::Utc::now() + chrono::Duration::seconds(60);
7662            let entries = j.entries_in_window(from, to).await;
7663            assert_eq!(entries.len(), 1, "journaled statement should be in window");
7664            assert!(entries[0].1.statement.contains("insert"));
7665        }
7666    }
7667
7668    // ---- schema-routing: OLAP vs OLTP workload classification ----
7669
7670    #[cfg(feature = "schema-routing")]
7671    mod schema_routing {
7672        use crate::schema_routing::{QueryAnalyzer, SchemaRegistry};
7673        use std::sync::Arc;
7674
7675        fn analyzer() -> QueryAnalyzer {
7676            QueryAnalyzer::new(Arc::new(SchemaRegistry::new()))
7677        }
7678
7679        #[test]
7680        fn aggregation_group_by_is_analytics() {
7681            let a = analyzer();
7682            assert!(a
7683                .analyze("select count(*) from orders group by region")
7684                .is_analytics());
7685        }
7686
7687        #[test]
7688        fn simple_point_query_is_not_analytics() {
7689            let a = analyzer();
7690            assert!(!a
7691                .analyze("select * from orders where id = 1")
7692                .is_analytics());
7693        }
7694    }
7695
7696    /// The session RAII guard must deregister the session and bump the
7697    /// connections-closed metric when dropped normally.
7698    #[tokio::test]
7699    async fn session_guard_deregisters_on_drop() {
7700        let server = ProxyServer::new(test_config()).unwrap();
7701        let state = server.state.clone();
7702        let session = make_test_session();
7703        state.sessions.insert(session.id, session.clone());
7704        assert_eq!(state.sessions.len(), 1);
7705        let before = state.metrics.connections_closed.load(Ordering::Relaxed);
7706        {
7707            let _g = SessionGuard {
7708                state: state.clone(),
7709                session_id: session.id,
7710            };
7711        }
7712        assert!(state.sessions.is_empty(), "guard must deregister on drop");
7713        assert_eq!(
7714            state.metrics.connections_closed.load(Ordering::Relaxed),
7715            before + 1
7716        );
7717    }
7718
7719    /// The critical property: the guard must deregister even when the connection
7720    /// task unwinds via a panic (the leak this replaces).
7721    #[tokio::test]
7722    async fn session_guard_deregisters_on_panic() {
7723        let server = ProxyServer::new(test_config()).unwrap();
7724        let state = server.state.clone();
7725        let session = make_test_session();
7726        state.sessions.insert(session.id, session.clone());
7727        let sid = session.id;
7728        let st = state.clone();
7729        let r = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
7730            let _g = SessionGuard {
7731                state: st,
7732                session_id: sid,
7733            };
7734            panic!("simulated connection-task panic");
7735        }));
7736        assert!(r.is_err(), "closure must have panicked");
7737        assert!(
7738            state.sessions.is_empty(),
7739            "guard must deregister on a panic unwind"
7740        );
7741    }
7742
7743    /// The conditional-reset classifier must call every session-state-creating
7744    /// statement DIRTY (so it is reset before reuse) and only provably neutral
7745    /// statements CLEAN. A false "clean" would leak state across clients, so the
7746    /// dirty cases here are the security-critical half of the test.
7747    #[cfg(feature = "pool-modes")]
7748    #[test]
7749    fn stmt_classifier_is_conservative() {
7750        let clean = ProxyServer::stmt_leaves_session_state;
7751        // ---- Provably clean (reset may be skipped) ----
7752        assert!(!clean(
7753            "SELECT abalance FROM pgbench_accounts WHERE aid = 12345"
7754        ));
7755        assert!(!clean("SELECT 1"));
7756        assert!(!clean("SELECT 1;")); // single trailing ';'
7757        assert!(!clean("  select now()  ")); // read of a volatile fn: no session state
7758        assert!(!clean("INSERT INTO t VALUES (1)")); // INTO is INSERT syntax, not SELECT INTO
7759        assert!(!clean("UPDATE t SET c = 1 WHERE id = 2")); // "SET" is UPDATE syntax, not a GUC
7760        assert!(!clean("DELETE FROM t WHERE id = 3"));
7761        assert!(!clean("WITH x AS (SELECT 1) SELECT * FROM x"));
7762        assert!(!clean("SELECT into_total FROM ledger")); // column named into_total, not INTO kw
7763        assert!(!clean("BEGIN"));
7764        assert!(!clean("COMMIT"));
7765        assert!(!clean("SELECT current_setting('work_mem')")); // reading a GUC is fine
7766
7767        // ---- Must be DIRTY (reset required) ----
7768        assert!(clean("SET work_mem = '1GB'"), "SET GUC");
7769        assert!(clean("set search_path to public"), "lowercase SET");
7770        assert!(clean("CREATE TEMP TABLE t(x int)"), "temp table");
7771        assert!(clean("CREATE TEMPORARY TABLE t(x int)"), "temp table");
7772        assert!(clean("SELECT * INTO TEMP t FROM src"), "SELECT INTO temp");
7773        assert!(clean("select a into t from s"), "SELECT INTO lowercase");
7774        assert!(clean("PREPARE p AS SELECT 1"), "prepared statement");
7775        assert!(clean("DEALLOCATE p"), "deallocate");
7776        assert!(
7777            clean("DECLARE c CURSOR WITH HOLD FOR SELECT 1"),
7778            "held cursor"
7779        );
7780        assert!(clean("LISTEN my_channel"), "listen");
7781        assert!(clean("SELECT pg_advisory_lock(42)"), "advisory lock");
7782        assert!(clean("SELECT pg_try_advisory_lock(1)"), "try advisory lock");
7783        assert!(
7784            clean("SELECT set_config('work_mem','1GB',false)"),
7785            "set_config fn"
7786        );
7787        assert!(clean("SELECT nextval('s')"), "sequence cache");
7788        assert!(clean("SET ROLE admin"), "set role");
7789        assert!(clean("SET SESSION AUTHORIZATION bob"), "session auth");
7790        assert!(clean("DISCARD ALL"), "explicit discard");
7791        assert!(clean("RESET ALL"), "reset");
7792        // Multi-statement: a neutral lead cannot vouch for what follows a ';'.
7793        assert!(clean("SELECT 1; SET work_mem='1GB'"), "hidden SET after ;");
7794        assert!(
7795            clean("SELECT 1; CREATE TEMP TABLE t(x int)"),
7796            "hidden temp after ;"
7797        );
7798        // ';' inside a literal → conservatively dirty (safe over-reset).
7799        assert!(clean("SELECT 'a;b'"), "semicolon in literal");
7800        // Non-neutral leads.
7801        assert!(clean("COPY t FROM STDIN"), "copy");
7802        assert!(clean("GRANT SELECT ON t TO bob"), "grant");
7803        assert!(clean("ALTER TABLE t ADD COLUMN c int"), "ddl");
7804    }
7805
7806    /// `reset_backend` must only report success when the reset query cleanly
7807    /// completed — no ErrorResponse and an idle ReadyForQuery. A poisoned reset
7808    /// (error, or a non-idle transaction status) must return `Err` so the caller
7809    /// drops the connection instead of parking it dirty (Group 2, 2.0.b).
7810    #[cfg(feature = "pool-modes")]
7811    #[tokio::test]
7812    async fn reset_backend_rejects_error_and_nonidle() {
7813        use tokio::io::AsyncWriteExt as _;
7814        fn frame(tag: u8, body: &[u8]) -> Vec<u8> {
7815            let mut v = vec![tag];
7816            v.extend_from_slice(&((body.len() + 4) as u32).to_be_bytes());
7817            v.extend_from_slice(body);
7818            v
7819        }
7820        let rfq = |st: u8| frame(b'Z', &[st]);
7821        let cc = frame(b'C', b"DISCARD ALL\0");
7822        let err = frame(b'E', b"SERROR\0C25P02\0Mreset failed\0\0");
7823
7824        // Clean: CommandComplete + ReadyForQuery('I') -> Ok.
7825        let (mut client, mut server) = tokio::io::duplex(4096);
7826        let mut resp = cc.clone();
7827        resp.extend_from_slice(&rfq(b'I'));
7828        server.write_all(&resp).await.unwrap();
7829        assert!(
7830            ProxyServer::reset_backend(&mut client, "DISCARD ALL")
7831                .await
7832                .is_ok(),
7833            "clean reset must succeed"
7834        );
7835
7836        // ErrorResponse before RFQ -> Err (connection is poisoned).
7837        let (mut client, mut server) = tokio::io::duplex(4096);
7838        let mut resp = err.clone();
7839        resp.extend_from_slice(&rfq(b'I'));
7840        server.write_all(&resp).await.unwrap();
7841        assert!(
7842            ProxyServer::reset_backend(&mut client, "DISCARD ALL")
7843                .await
7844                .is_err(),
7845            "reset that errored must be rejected"
7846        );
7847
7848        // Non-idle status ('T') -> Err (still in a transaction).
7849        let (mut client, mut server) = tokio::io::duplex(4096);
7850        let mut resp = cc.clone();
7851        resp.extend_from_slice(&rfq(b'T'));
7852        server.write_all(&resp).await.unwrap();
7853        assert!(
7854            ProxyServer::reset_backend(&mut client, "DISCARD ALL")
7855                .await
7856                .is_err(),
7857            "reset leaving a non-idle txn must be rejected"
7858        );
7859    }
7860
7861    /// The pool identity key stays the bare `(node,user,db)` triple when no
7862    /// routing-relevant startup GUC is set (backward-compatible with existing
7863    /// pooling), but diverges when a client sets a different `client_encoding` /
7864    /// `DateStyle` / etc., so such clients never share a connection (Group 2,
7865    /// 2.0.c).
7866    #[cfg(feature = "pool-modes")]
7867    #[tokio::test]
7868    async fn pool_key_folds_startup_params() {
7869        let base = make_test_session();
7870        {
7871            let mut v = base.variables.write().await;
7872            v.insert("user".into(), "u".into());
7873            v.insert("database".into(), "d".into());
7874        }
7875        let k_plain = ProxyServer::pool_key_for("n:5432", &base).await;
7876        assert_eq!(k_plain, crate::pool::pool_key("n:5432", "u", "d"));
7877
7878        // Same identity but a distinct client_encoding must produce a
7879        // different key (no cross-encoding sharing).
7880        let utf8 = make_test_session();
7881        let latin1 = make_test_session();
7882        for (s, enc) in [(&utf8, "UTF8"), (&latin1, "LATIN1")] {
7883            let mut v = s.variables.write().await;
7884            v.insert("user".into(), "u".into());
7885            v.insert("database".into(), "d".into());
7886            v.insert("client_encoding".into(), enc.into());
7887        }
7888        let k_utf8 = ProxyServer::pool_key_for("n:5432", &utf8).await;
7889        let k_latin1 = ProxyServer::pool_key_for("n:5432", &latin1).await;
7890        assert_ne!(k_utf8, k_latin1, "different client_encoding must not share");
7891        assert_ne!(k_utf8, k_plain, "GUC-bearing key must differ from bare key");
7892    }
7893}