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