Skip to main content

netget/server/postgresql/
mod.rs

1//! PostgreSQL server implementation using pgwire
2pub mod actions;
3
4use crate::llm::action_helper::call_llm;
5use crate::llm::actions::protocol_trait::ActionResult;
6use crate::llm::ollama_client::OllamaClient;
7use crate::protocol::Event;
8use crate::server::connection::ConnectionId;
9use crate::state::app_state::AppState;
10use crate::{console_debug, console_error};
11use actions::{PostgresqlProtocol, POSTGRESQL_QUERY_EVENT};
12use anyhow::Result;
13use pgwire::api::auth::noop::NoopStartupHandler;
14use pgwire::api::auth::StartupHandler;
15use pgwire::api::portal::Portal;
16use pgwire::api::query::{ExtendedQueryHandler, SimpleQueryHandler};
17use pgwire::api::results::{
18    DataRowEncoder, DescribePortalResponse, DescribeStatementResponse, FieldFormat, FieldInfo,
19    QueryResponse, Response, Tag,
20};
21use pgwire::api::stmt::StoredStatement;
22use pgwire::api::{ClientInfo, PgWireServerHandlers, Type};
23use pgwire::error::{ErrorInfo, PgWireError, PgWireResult};
24use pgwire::messages::data::DataRow;
25use pgwire::tokio::process_socket;
26use pgwire::types::format::FormatOptions;
27use std::net::SocketAddr;
28use std::sync::Arc;
29use tokio::net::TcpListener;
30use tokio::sync::{mpsc, Mutex as TokioMutex};
31use tracing::{debug, error, info, warn};
32
33/// PostgreSQL server implementation
34pub struct PostgresqlServer {
35    llm_client: OllamaClient,
36    app_state: Arc<AppState>,
37    #[allow(dead_code)]
38    status_tx: mpsc::UnboundedSender<String>,
39    server_id: Option<crate::state::ServerId>,
40}
41
42impl PostgresqlServer {
43    /// Create a new PostgreSQL server
44    pub fn new(
45        llm_client: OllamaClient,
46        app_state: Arc<AppState>,
47        status_tx: mpsc::UnboundedSender<String>,
48        server_id: Option<crate::state::ServerId>,
49    ) -> Self {
50        Self {
51            llm_client,
52            app_state,
53            status_tx,
54            server_id,
55        }
56    }
57
58    /// Spawn PostgreSQL server with LLM integration
59    pub async fn spawn_with_llm_actions(
60        listen_addr: SocketAddr,
61        llm_client: OllamaClient,
62        app_state: Arc<AppState>,
63        status_tx: mpsc::UnboundedSender<String>,
64        _send_first: bool,
65        server_id: crate::state::ServerId,
66    ) -> Result<SocketAddr> {
67        let listener = TcpListener::bind(listen_addr).await?;
68        let actual_addr = listener.local_addr()?;
69
70        info!("PostgreSQL server starting on {}", actual_addr);
71        let _ = status_tx.send(format!(
72            "[INFO] PostgreSQL server listening on {}",
73            actual_addr
74        ));
75
76        let server = Arc::new(PostgresqlServer::new(
77            llm_client,
78            app_state.clone(),
79            status_tx.clone(),
80            Some(server_id),
81        ));
82
83        let status_tx_clone = status_tx.clone();
84        let task_registrar = app_state.clone();
85
86        // Spawn the accept loop
87        let limiter = crate::server::accept_bounded::ConnectionLimiter::new(MAX_CONNECTIONS);
88        let accept_handle = tokio::spawn(async move {
89            loop {
90                match crate::server::accept_bounded::accept_bounded(
91                    &listener,
92                    &limiter,
93                    CONNECTION_CAP_REFUSAL,
94                    "PostgreSQL",
95                    Some(&status_tx),
96                )
97                .await
98                {
99                    Ok((stream, addr, permit)) => {
100                        console_debug!(status_tx, "PostgreSQL connection from {}", addr);
101
102                        let connection_id =
103                            ConnectionId::new(app_state.get_next_unified_id().await);
104                        let local_addr_conn = stream.local_addr().unwrap_or(actual_addr);
105
106                        let handler_factory = Arc::new(PostgresqlHandlerFactory {
107                            connection_id,
108                            llm_client: server.llm_client.clone(),
109                            app_state: server.app_state.clone(),
110                            status_tx: status_tx.clone(),
111                            server_id: server.server_id,
112                            remote_addr: addr,
113                            describe_cache: Arc::new(TokioMutex::new(Vec::new())),
114                            activity: Arc::new(
115                                crate::server::accept_bounded::ConnectionActivity::new(),
116                            ),
117                        });
118                        let activity = Arc::clone(&handler_factory.activity);
119
120                        // Track the connection
121                        if let Some(server_id) = server.server_id {
122                            use crate::state::server::{
123                                ConnectionState as ServerConnectionState, ConnectionStatus,
124                                ProtocolConnectionInfo,
125                            };
126                            let now = crate::utils::clock::Instant::now();
127                            let conn_state = ServerConnectionState {
128                                id: connection_id,
129                                remote_addr: addr,
130                                local_addr: local_addr_conn,
131                                bytes_sent: 0,
132                                bytes_received: 0,
133                                packets_sent: 0,
134                                packets_received: 0,
135                                last_activity: now,
136                                status: ConnectionStatus::Active,
137                                status_changed_at: now,
138                                protocol_info: ProtocolConnectionInfo::empty(),
139                            };
140                            server
141                                .app_state
142                                .add_connection_to_server(server_id, conn_state)
143                                .await;
144                        }
145
146                        let conn_state_owner = server.app_state.clone();
147                        let conn_server_id = server.server_id;
148                        let conn_status_tx = status_tx.clone();
149                        let conn_handle = tokio::spawn(async move {
150                            // Held for the life of the session, so the cap counts live
151                            // connections rather than accepts.
152                            let _permit = permit;
153
154                            // First-byte bound, enforced before pgwire sees the socket.
155                            //
156                            // `process_socket` takes a concrete `TcpStream` and owns every read
157                            // after that, so there is no reader of ours to wrap — but `peek`
158                            // waits for data *without consuming it*, so the StartupMessage is
159                            // still there for pgwire afterwards. That makes a real
160                            // "connected and said nothing" bound possible with no proxy and no
161                            // change to the crate's framing.
162                            match tokio::time::timeout(
163                                FIRST_BYTE_READ_TIMEOUT,
164                                stream.peek(&mut [0u8; 1]),
165                            )
166                            .await
167                            {
168                                Ok(Ok(0)) | Ok(Err(_)) => return,
169                                Ok(Ok(_)) => {}
170                                Err(_) => {
171                                    console_debug!(
172                                        conn_status_tx,
173                                        "PostgreSQL connection {} sent nothing for {}s; \
174                                         closing before startup",
175                                        connection_id,
176                                        FIRST_BYTE_READ_TIMEOUT.as_secs()
177                                    );
178                                    return;
179                                }
180                            }
181
182                            // `process_socket` runs in a task of its own so a **panic** inside
183                            // pgwire is contained rather than lost.
184                            //
185                            // pgwire 0.35's `decode_packet` bounds a message's declared length
186                            // only from above and then hands `decode_fn` the whole remaining
187                            // buffer, so `get_cstring` can call `split_to(remaining + 1)` and
188                            // panic on a message carrying no NUL — six bytes after a valid
189                            // startup handshake are enough (IMPROVEMENTS #77). The fix belongs
190                            // upstream: pgwire owns the socket loop and takes a concrete
191                            // `TcpStream`, so nothing here can bound the frame without
192                            // proxying the connection.
193                            //
194                            // What NetGet can do is not lose the connection's bookkeeping to
195                            // it. The panic used to unwind straight past
196                            // `close_connection_on_server`, so the dashboard kept an Active row
197                            // for a socket that was already gone — and PostgreSQL is not
198                            // `connectionless`, so the idle sweep never collected it either.
199                            // The entry now closes on every path, and the panic is reported
200                            // rather than swallowed by `tokio::spawn`.
201                            let inner = tokio::spawn(async move {
202                                process_socket(stream, None, handler_factory).await
203                            });
204
205                            // Idle bound. pgwire's loop is not ours, so this watches the
206                            // handler's own `ConnectionActivity` instead — and that type
207                            // refuses to report a connection idle while work is in flight, so
208                            // a statement waiting on the model, or parked for a human at the
209                            // dashboard, can never be closed from under itself however long it
210                            // takes. Only genuine silence counts.
211                            //
212                            // The connection is dropped rather than sent a FATAL 57P05: pgwire
213                            // owns the socket and exposes no way to write into it from here.
214                            let abort_handle = inner.abort_handle();
215                            let watchdog_activity = Arc::clone(&activity);
216                            let watchdog_status = conn_status_tx.clone();
217                            let watchdog = tokio::spawn(async move {
218                                crate::server::accept_bounded::watch_idle(
219                                    watchdog_activity,
220                                    IDLE_SESSION_TIMEOUT,
221                                )
222                                .await;
223                                console_debug!(
224                                    watchdog_status,
225                                    "PostgreSQL connection {} idle for {}s; closing",
226                                    connection_id,
227                                    IDLE_SESSION_TIMEOUT.as_secs()
228                                );
229                                abort_handle.abort();
230                            });
231
232                            let joined = inner.await;
233                            watchdog.abort();
234                            match joined {
235                                Ok(Ok(())) => {}
236                                Ok(Err(e)) => {
237                                    error!("PostgreSQL connection error: {:?}", e);
238                                }
239                                Err(join_error) if join_error.is_panic() => {
240                                    error!(
241                                        "PostgreSQL connection {} decision=connection_aborted_decoder_panic: \
242                                         pgwire panicked decoding a message from the peer (a \
243                                         malformed frame; IMPROVEMENTS #77). The connection is \
244                                         closed; the server is unaffected.",
245                                        connection_id
246                                    );
247                                    let _ = conn_status_tx.send(format!(
248                                        "[ERROR] PostgreSQL connection {} killed by a malformed \
249                                         message (pgwire decoder panic)",
250                                        connection_id
251                                    ));
252                                }
253                                Err(join_error) => {
254                                    error!(
255                                        "PostgreSQL connection {} task ended abnormally: {}",
256                                        connection_id, join_error
257                                    );
258                                }
259                            }
260                            // Mark the connection closed so it does not stay Active forever
261                            // in the server's connection map.
262                            if let Some(server_id) = conn_server_id {
263                                conn_state_owner
264                                    .close_connection_on_server(server_id, connection_id)
265                                    .await;
266                            }
267                        });
268
269                        // Register the per-connection task too, not just the accept loop:
270                        // aborting the accept loop releases the port but leaves every in-flight
271                        // session running, so `stop_server` did not actually stop the server.
272                        // `register_server_task` prunes finished handles on each call, so this
273                        // cannot grow without bound.
274                        if let Some(server_id) = conn_server_id {
275                            app_state.register_server_task(server_id, conn_handle).await;
276                        }
277                    }
278                    Err(e) => {
279                        console_error!(status_tx, "PostgreSQL accept error: {}", e);
280                    }
281                }
282            }
283        });
284
285        task_registrar
286            .register_server_task(server_id, accept_handle)
287            .await;
288
289        let _ = status_tx_clone.send("__UPDATE_UI__".to_string());
290        Ok(actual_addr)
291    }
292}
293
294/// No-auth startup handler for PostgreSQL
295struct PostgresqlNoopHandler;
296
297// Implement NoopStartupHandler trait
298// StartupHandler is automatically implemented for types implementing NoopStartupHandler
299#[async_trait::async_trait]
300impl NoopStartupHandler for PostgresqlNoopHandler {}
301
302/// Factory for creating PostgreSQL handlers
303struct PostgresqlHandlerFactory {
304    connection_id: ConnectionId,
305    llm_client: OllamaClient,
306    app_state: Arc<AppState>,
307    status_tx: mpsc::UnboundedSender<String>,
308    server_id: Option<crate::state::ServerId>,
309    remote_addr: SocketAddr,
310    /// Shared between the simple and extended handlers so a Describe resolved by one is
311    /// visible to the Execute served by the other.
312    describe_cache: DescribeCache,
313    /// Read by the idle watchdog in the accept loop. pgwire owns the socket, so this is the
314    /// only view NetGet has of whether the connection is doing anything.
315    activity: Arc<crate::server::accept_bounded::ConnectionActivity>,
316}
317
318impl PostgresqlHandlerFactory {
319    fn handler(&self) -> PostgresqlHandler {
320        PostgresqlHandler {
321            activity: Arc::clone(&self.activity),
322            connection_id: self.connection_id,
323            llm_client: self.llm_client.clone(),
324            app_state: self.app_state.clone(),
325            status_tx: self.status_tx.clone(),
326            server_id: self.server_id,
327            remote_addr: self.remote_addr,
328            protocol: Arc::new(PostgresqlProtocol::new(
329                self.connection_id,
330                self.app_state.clone(),
331                self.status_tx.clone(),
332            )),
333            describe_cache: Arc::clone(&self.describe_cache),
334        }
335    }
336}
337
338impl PgWireServerHandlers for PostgresqlHandlerFactory {
339    fn simple_query_handler(&self) -> Arc<impl SimpleQueryHandler> {
340        Arc::new(self.handler())
341    }
342
343    fn extended_query_handler(&self) -> Arc<impl ExtendedQueryHandler> {
344        Arc::new(self.handler())
345    }
346
347    fn startup_handler(&self) -> Arc<impl StartupHandler> {
348        Arc::new(PostgresqlNoopHandler)
349    }
350}
351
352/// PostgreSQL connection handler
353pub struct PostgresqlHandler {
354    connection_id: ConnectionId,
355    llm_client: OllamaClient,
356    app_state: Arc<AppState>,
357    status_tx: mpsc::UnboundedSender<String>,
358    #[allow(dead_code)]
359    server_id: Option<crate::state::ServerId>,
360    #[allow(dead_code)]
361    remote_addr: SocketAddr,
362    /// PostgreSQL protocol handler for action execution
363    protocol: Arc<PostgresqlProtocol>,
364    /// Describe -> Execute correlation for the extended query protocol
365    describe_cache: DescribeCache,
366    /// See [`PostgresqlHandlerFactory::activity`].
367    activity: Arc<crate::server::accept_bounded::ConnectionActivity>,
368}
369
370/// The outcome of resolving one SQL statement through the LLM/handler pipeline.
371///
372/// Extended-protocol clients ask for the row description (Describe) *before* asking for the
373/// rows (Execute). Because the schema is whatever the LLM decides, the two steps must agree,
374/// so the resolved outcome is cached per SQL text between them. This is per-connection
375/// protocol bookkeeping, not a data store: nothing is retained once the statement executes.
376enum PgOutcome {
377    Rows {
378        /// The column descriptors the model supplied, kept as name + type rather than as
379        /// `FieldInfo`, because a `FieldInfo` also carries a *format* and the format is not
380        /// known here. Only the client knows it: the Bind message names the result format for
381        /// each column, so the same resolved answer has to be encoded as text for one portal
382        /// and as binary for another. Encoding is therefore deferred to the handler.
383        columns: Arc<Vec<PgColumn>>,
384        /// One entry per row, already padded and truncated to `columns.len()`.
385        values: Vec<Vec<serde_json::Value>>,
386    },
387    Tag(String),
388    Close,
389}
390
391/// A column as the model described it: a name and a PostgreSQL type OID.
392#[derive(Clone, Debug)]
393struct PgColumn {
394    name: String,
395    datatype: Type,
396}
397
398/// Cap on described-but-not-executed statements held per connection.
399const MAX_PENDING_DESCRIBES: usize = 64;
400
401/// How long to wait for the peer's first byte after it connects.
402///
403/// PostgreSQL is client-speaks-first: the StartupMessage (or an SSLRequest) is the first thing
404/// on the wire and the server says nothing before it, so a peer that has connected and sent
405/// nothing has begun nothing. This is enforced with `TcpStream::peek` *before* the socket is
406/// handed to pgwire — peek waits for data without consuming it, so the startup packet pgwire
407/// then reads is untouched. Sixty seconds matches `pgwire`'s own internal `STARTUP_TIMEOUT`,
408/// which bounds the startup *exchange*; this bounds the silence before it begins, which pgwire
409/// does not.
410const FIRST_BYTE_READ_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(60);
411
412/// How long a connection may do nothing at all once startup is over.
413///
414/// Real PostgreSQL's equivalent is `idle_session_timeout`, which ships disabled — so unlike
415/// Kafka there is no upstream default to copy, and the number has to be argued. Ten minutes,
416/// for the same reason MySQL's is: this server holds no session state a reconnect would lose
417/// (no tables, no temporary tables, no open transaction — the model answers every statement),
418/// so a pooled connection reaped here costs `tokio-postgres`, SQLAlchemy or pgbouncer one
419/// transparent reconnect. It is also far above the default 300s a `manual` rule may park a
420/// statement for, and — more importantly — a park cannot trip it at all, because
421/// [`ConnectionActivity`] counts work in flight and a busy connection is never idle.
422const IDLE_SESSION_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(600);
423
424/// Concurrent connections this server admits.
425const MAX_CONNECTIONS: usize = crate::server::accept_bounded::DEFAULT_MAX_CONNECTIONS;
426
427/// What a peer over [`MAX_CONNECTIONS`] is told before the socket closes.
428///
429/// A v3 ErrorResponse carrying SQLSTATE **53300 `too_many_connections`** — the state real
430/// PostgreSQL uses for "sorry, too many clients already", and the one this file already maps
431/// backend overload onto, so a driver classifies both as transient and backs off rather than
432/// recording a permanent fault. Sending it before the StartupMessage is a small liberty (a real
433/// server refuses after reading startup), and libpq-family clients handle it: they write
434/// startup, then read, and an ErrorResponse is a legal thing to find there.
435///
436/// Laid out by hand: `'E'`, a 4-byte big-endian length covering itself and everything after
437/// it, then NUL-terminated fields `S`everity, `V` (non-localised severity), `C`ode and
438/// `M`essage, then a final NUL ending the field list. 4 + 7 + 7 + 7 + 22 + 1 = 48 = 0x30.
439const CONNECTION_CAP_REFUSAL: &[u8] =
440    b"E\x00\x00\x000SFATAL\x00VFATAL\x00C53300\x00Mtoo many connections\x00\x00";
441
442/// A connection-scoped cache shared by the simple and extended handlers.
443type DescribeCache = Arc<TokioMutex<Vec<(String, PgOutcome)>>>;
444
445impl PostgresqlHandler {
446    /// Refresh the dashboard's per-connection counters and `last_activity`.
447    ///
448    /// `pgwire::tokio::process_socket` takes a concrete `TcpStream` and never exposes the raw
449    /// byte streams, so these are the **application-visible** payload sizes seen at the handler
450    /// boundary (the SQL text received, the cell text produced), not the exact wire bytes —
451    /// pgwire adds a 5-byte message header, the RowDescription and per-field length prefixes on
452    /// top. Good enough for the rail's `↓/↑` counters and, more importantly, for keeping
453    /// `last_activity` current: without this nothing on this protocol ever called
454    /// `update_connection_stats`, so every PostgreSQL peer row read `0 / 0` for its whole life
455    /// however much SQL crossed it.
456    async fn record_stats(
457        &self,
458        bytes_in: Option<u64>,
459        bytes_out: Option<u64>,
460        packets_in: Option<u64>,
461        packets_out: Option<u64>,
462    ) {
463        if let Some(server_id) = self.server_id {
464            self.app_state
465                .update_connection_stats(
466                    server_id,
467                    self.connection_id,
468                    bytes_in,
469                    bytes_out,
470                    packets_in,
471                    packets_out,
472                )
473                .await;
474        }
475    }
476
477    /// Run one statement through the handler pipeline and translate the resulting action into
478    /// wire-level output. Returns `Err` for `postgresql_error_response` and for LLM failures.
479    async fn resolve(&self, sql: &str) -> PgWireResult<PgOutcome> {
480        // Everything from here to the end of this call is the peer *waiting on us* — the LLM
481        // round-trip, and a `manual` rule parking the statement for a human. The idle watchdog
482        // must not count any of it, so the connection is marked busy for the whole of it and
483        // the guard's `Drop` clears the mark on every exit path, including the `?`-shaped ones.
484        let _busy = self.activity.busy();
485        debug!("PostgreSQL query: {}", sql);
486        let _ = self
487            .status_tx
488            .send(format!("[DEBUG] PostgreSQL query: {}", sql));
489
490        self.record_stats(Some(sql.len() as u64), None, Some(1), None)
491            .await;
492
493        let mut event_data = serde_json::json!({ "query": sql });
494        if let Some(hint) = actions::answer_with_for_query(sql) {
495            event_data["answer_with"] = serde_json::json!(hint);
496        }
497        let event = Event::new(&POSTGRESQL_QUERY_EVENT, event_data);
498
499        let server_id = self
500            .server_id
501            .unwrap_or_else(|| crate::state::ServerId::new(0));
502
503        let execution_result = match call_llm(
504            &self.llm_client,
505            &self.app_state,
506            server_id,
507            Some(self.connection_id),
508            &event,
509            self.protocol.as_ref(),
510        )
511        .await
512        {
513            Ok(result) => result,
514            Err(e) => {
515                // An ErrorResponse carrying a SQLSTATE, not silence: a client that gets
516                // nothing back sits in its own read until it times out, and cannot tell an
517                // unavailable backend from a slow query.
518                //
519                // Overload is reported as 53300 (too_many_connections, class 53 "insufficient
520                // resources"), which drivers classify as transient; everything else stays
521                // XX000 (internal_error). The two are deliberately distinguishable — an outage
522                // must not look like a permanent fault, and neither may look like success.
523                //
524                // `decision=` tags mirror `radius`: the SQLSTATE alone cannot tell a backend
525                // outage from a model that answered and said nothing, so the log must.
526                let overloaded = crate::llm::is_overload_error(&e);
527                let (decision, code) = if overloaded {
528                    ("fail_closed_llm_overloaded", "53300")
529                } else {
530                    ("fail_closed_llm_error", "XX000")
531                };
532                error!(
533                    "PostgreSQL connection {} decision={} sqlstate={}: {}",
534                    self.connection_id, decision, code, e
535                );
536                let message = crate::utils::WireFailure::classify(&e).prefixed_text();
537                let _ = self
538                    .status_tx
539                    .send(format!("[ERROR] PostgreSQL {}: {}", code, message));
540                self.record_stats(None, Some(message.len() as u64), None, Some(1))
541                    .await;
542                return Err(PgWireError::UserError(Box::new(ErrorInfo::new(
543                    "ERROR".to_string(),
544                    code.to_string(),
545                    message.to_string(),
546                ))));
547            }
548        };
549
550        let mut close_requested = false;
551
552        // One query, one answer. The first response action is the one sent; any further one
553        // is dropped, and said so. The eval saw a model answer one SELECT with two
554        // `postgresql_query_response`s (the second one complete, the first not): a second
555        // result for the same query has nowhere to go on the wire, and a silent drop would
556        // make it look as though the model's last word was the one the client got.
557        let responses = execution_result
558            .protocol_results
559            .iter()
560            .filter(|r| {
561                matches!(r, ActionResult::Custom { name, .. }
562                    if matches!(name.as_str(), "postgresql_query_response" | "postgresql_ok" | "postgresql_error"))
563            })
564            .count();
565        if responses > 1 {
566            warn!(
567                "PostgreSQL connection {} decision=duplicate_response_dropped: {} response \
568                 actions for one query; sending the first",
569                self.connection_id, responses
570            );
571        }
572
573        for result in execution_result.protocol_results {
574            match result {
575                ActionResult::CloseConnection => close_requested = true,
576                ActionResult::Custom { name, data } => match name.as_str() {
577                    "postgresql_query_response" => {
578                        let columns = data
579                            .get("columns")
580                            .and_then(|v| v.as_array())
581                            .cloned()
582                            .unwrap_or_default();
583                        let rows = data
584                            .get("rows")
585                            .and_then(|v| v.as_array())
586                            .cloned()
587                            .unwrap_or_default();
588                        // Estimate the outbound payload from the cell text while the JSON is
589                        // still in hand — a `DataRow` does not expose its encoded length.
590                        let sent_bytes: u64 = columns
591                            .iter()
592                            .filter_map(|c| c.get("name"))
593                            .filter_map(|v| v.as_str())
594                            .map(|s| s.len() as u64)
595                            .sum::<u64>()
596                            + rows
597                                .iter()
598                                .filter_map(|r| r.as_array())
599                                .flatten()
600                                .map(|v| json_value_to_string(v).len() as u64)
601                                .sum::<u64>();
602                        self.record_stats(None, Some(sent_bytes), None, Some(1))
603                            .await;
604                        return build_row_outcome(&columns, &rows);
605                    }
606                    "postgresql_ok" => {
607                        let tag = data.get("tag").and_then(|v| v.as_str()).unwrap_or("OK");
608                        self.record_stats(None, Some(tag.len() as u64), None, Some(1))
609                            .await;
610                        return Ok(PgOutcome::Tag(tag.to_string()));
611                    }
612                    "postgresql_error" => {
613                        let severity = data
614                            .get("severity")
615                            .and_then(|v| v.as_str())
616                            .unwrap_or("ERROR")
617                            .to_string();
618                        let code = data
619                            .get("code")
620                            .and_then(|v| v.as_str())
621                            .unwrap_or("XX000")
622                            .to_string();
623                        let message = data
624                            .get("message")
625                            .and_then(|v| v.as_str())
626                            .unwrap_or("Unknown error")
627                            .to_string();
628
629                        let _ = self.status_tx.send(format!(
630                            "[ERROR] PostgreSQL error {} {}: {}",
631                            severity, code, message
632                        ));
633                        // A refusal the model chose, not a backend failure — tagged so the log
634                        // distinguishes the two, as `radius` does.
635                        warn!(
636                            "PostgreSQL connection {} decision=model_reject sqlstate={}: {}",
637                            self.connection_id, code, message
638                        );
639                        self.record_stats(None, Some(message.len() as u64), None, Some(1))
640                            .await;
641
642                        return Err(PgWireError::UserError(Box::new(ErrorInfo::new(
643                            severity, code, message,
644                        ))));
645                    }
646                    other => {
647                        warn!(
648                            "PostgreSQL: no wire encoding for action result '{}', ignoring",
649                            other
650                        );
651                    }
652                },
653                _ => {}
654            }
655        }
656
657        if close_requested {
658            return Ok(PgOutcome::Close);
659        }
660
661        // No response action matched: the handler ran but produced nothing this protocol can
662        // encode — a model that refused, a static handler with an empty list, or an answer
663        // whose actions were all unrecognised.
664        //
665        // This used to answer success. A SELECT got an empty result set, which reads as "the
666        // query ran and matched no rows" — a factual claim about the data that nothing
667        // supports. Worse, anything else got the command tag `OK`, so an INSERT, UPDATE or
668        // DELETE the model declined was reported to the client as having completed, and a
669        // caller would carry on believing the write landed.
670        //
671        // Neither is recoverable by the client, because success is indistinguishable from a
672        // real one. Fail closed instead, with the same shape the backend-error path above uses
673        // so the two are consistent on the wire. 02000 (no_data) is deliberately NOT used: it
674        // would again assert something about the data rather than about netget.
675        warn!(
676            "PostgreSQL: no response action produced for {:?} (decision=fail_closed_no_action)",
677            sql
678        );
679        let _ = self.status_tx.send(
680            "[ERROR] PostgreSQL: no response action produced (decision=fail_closed_no_action)"
681                .to_string(),
682        );
683        self.record_stats(
684            None,
685            Some(crate::utils::WireFailure::Unavailable.prefixed_text().len() as u64),
686            None,
687            Some(1),
688        )
689        .await;
690        Err(PgWireError::UserError(Box::new(ErrorInfo::new(
691            "ERROR".to_string(),
692            "XX000".to_string(),
693            crate::utils::WireFailure::Unavailable
694                .prefixed_text()
695                .to_string(),
696        ))))
697    }
698
699    /// Resolve `sql`, storing the outcome so a following Execute reuses it (one LLM call per
700    /// extended-protocol statement instead of one per Describe *and* one per Execute).
701    async fn resolve_for_describe(
702        &self,
703        sql: &str,
704        format_of: impl Fn(usize, usize) -> FieldFormat,
705    ) -> PgWireResult<Vec<FieldInfo>> {
706        let outcome = self.resolve(sql).await?;
707        let fields = match &outcome {
708            PgOutcome::Rows { columns, .. } => {
709                fields_for(columns, |idx| format_of(idx, columns.len()))
710                    .as_ref()
711                    .clone()
712            }
713            _ => Vec::new(),
714        };
715
716        let mut cache = self.describe_cache.lock().await;
717        cache.retain(|(key, _)| key != sql);
718        if cache.len() >= MAX_PENDING_DESCRIBES {
719            cache.remove(0);
720        }
721        cache.push((sql.to_string(), outcome));
722
723        Ok(fields)
724    }
725
726    /// Take a previously described outcome, or resolve fresh if Execute arrived without one.
727    async fn take_or_resolve(&self, sql: &str) -> PgWireResult<PgOutcome> {
728        let cached = {
729            let mut cache = self.describe_cache.lock().await;
730            cache
731                .iter()
732                .position(|(key, _)| key == sql)
733                .map(|idx| cache.remove(idx).1)
734        };
735
736        match cached {
737            Some(outcome) => Ok(outcome),
738            None => self.resolve(sql).await,
739        }
740    }
741}
742
743/// Error returned when the LLM asked to close the connection.
744fn terminating_error() -> PgWireError {
745    PgWireError::UserError(Box::new(ErrorInfo::new(
746        "FATAL".to_string(),
747        "57P01".to_string(),
748        "terminating connection due to administrator command".to_string(),
749    )))
750}
751
752/// Map a column type name from the LLM onto a PostgreSQL type OID.
753fn pg_type_for(type_name: &str) -> Type {
754    match type_name.to_lowercase().as_str() {
755        "int2" | "smallint" => Type::INT2,
756        "int4" | "int" | "integer" => Type::INT4,
757        "int8" | "bigint" => Type::INT8,
758        "float4" | "real" => Type::FLOAT4,
759        "float8" | "double" | "double precision" => Type::FLOAT8,
760        "bool" | "boolean" => Type::BOOL,
761        "date" => Type::DATE,
762        "time" => Type::TIME,
763        "timestamp" => Type::TIMESTAMP,
764        _ => Type::VARCHAR,
765    }
766}
767
768/// Normalise LLM-supplied columns and rows into a resolved outcome.
769///
770/// Nothing is encoded here: the wire format of each cell depends on what the client asked for
771/// in its Bind message, which this function cannot see. See `PgOutcome::Rows`.
772fn build_row_outcome(
773    columns: &[serde_json::Value],
774    rows: &[serde_json::Value],
775) -> PgWireResult<PgOutcome> {
776    let columns: Vec<PgColumn> = columns
777        .iter()
778        .enumerate()
779        .map(|(idx, col)| {
780            let name = col
781                .get("name")
782                .and_then(|v| v.as_str())
783                .map(|s| s.to_string())
784                .unwrap_or_else(|| format!("column{}", idx + 1));
785            let type_name = col.get("type").and_then(|v| v.as_str()).unwrap_or("text");
786            PgColumn {
787                name,
788                datatype: pg_type_for(type_name),
789            }
790        })
791        .collect();
792
793    let mut values = Vec::with_capacity(rows.len());
794    for row_data in rows {
795        let Some(row_values) = row_data.as_array() else {
796            warn!(
797                "PostgreSQL: skipping row that is not an array: {}",
798                row_data
799            );
800            continue;
801        };
802        // A short row is padded with NULLs and a long row is truncated: PostgreSQL requires
803        // exactly one value per described column, and the LLM does occasionally miscount.
804        if row_values.len() != columns.len() {
805            warn!(
806                "PostgreSQL: row has {} value(s) for {} column(s); padding/truncating",
807                row_values.len(),
808                columns.len()
809            );
810        }
811        values.push(
812            (0..columns.len())
813                .map(|idx| {
814                    row_values
815                        .get(idx)
816                        .cloned()
817                        .unwrap_or(serde_json::Value::Null)
818                })
819                .collect(),
820        );
821    }
822
823    Ok(PgOutcome::Rows {
824        columns: Arc::new(columns),
825        values,
826    })
827}
828
829/// The result format the client asked for, for column `idx`.
830///
831/// `pgwire`'s `Format::format_for` indexes `Individual(codes)[idx]` unchecked, and the client
832/// chooses both how many format codes to send and — through the model's answer — how many
833/// columns come back, so a Bind naming fewer codes than we produce columns would panic the
834/// connection task. Text is the protocol's default for an unspecified column.
835fn format_for(format: &pgwire::api::portal::Format, idx: usize, columns: usize) -> FieldFormat {
836    match format {
837        pgwire::api::portal::Format::Individual(codes) if idx >= codes.len() => {
838            warn!(
839                "PostgreSQL: Bind sent {} result format code(s) for {} column(s); defaulting \
840                 column {} to text",
841                codes.len(),
842                columns,
843                idx
844            );
845            FieldFormat::Text
846        }
847        other => other.format_for(idx),
848    }
849}
850
851/// Build the row description for `columns`, taking each column's wire format from `format_of`.
852fn fields_for(
853    columns: &[PgColumn],
854    format_of: impl Fn(usize) -> FieldFormat,
855) -> Arc<Vec<FieldInfo>> {
856    Arc::new(
857        columns
858            .iter()
859            .enumerate()
860            .map(|(idx, col)| {
861                FieldInfo::new(
862                    col.name.clone(),
863                    None,
864                    None,
865                    col.datatype.clone(),
866                    format_of(idx),
867                )
868            })
869            .collect(),
870    )
871}
872
873/// Encode each row against `fields`, using the format each field carries.
874fn encode_rows(
875    fields: &Arc<Vec<FieldInfo>>,
876    values: &[Vec<serde_json::Value>],
877) -> Vec<PgWireResult<DataRow>> {
878    values
879        .iter()
880        .map(|row| {
881            let mut encoder = DataRowEncoder::new(Arc::clone(fields));
882            for (idx, field) in fields.iter().enumerate() {
883                let value = row.get(idx).unwrap_or(&serde_json::Value::Null);
884                encode_value(&mut encoder, field.datatype(), field.format(), value)?;
885            }
886            encoder.finish()
887        })
888        .collect()
889}
890
891/// Encode one cell.
892///
893/// `format` is not decoration: in the extended protocol tokio-postgres (and libpq, and every
894/// other driver) binds with **binary** result formats by default and then decodes each cell by
895/// the type in the RowDescription. This used to hardcode `FieldFormat::Text` for every cell
896/// while the client had asked for binary, so `SELECT` through `client.query(...)` came back as
897/// "error deserializing column 0" for every non-text column — while `simple_query`, which has
898/// no format negotiation and is always text, worked perfectly. Every test in this directory
899/// used `simple_query`, so the whole extended path went unmeasured; the tests' own CLAUDE.md
900/// recorded the symptom as an unexplained "Extended Query Protocol Timeout … UNRESOLVED".
901fn encode_value(
902    encoder: &mut DataRowEncoder,
903    field_type: &Type,
904    format: FieldFormat,
905    value: &serde_json::Value,
906) -> PgWireResult<()> {
907    if value.is_null() {
908        return encoder.encode_field_with_type_and_format(
909            &None::<&str>,
910            field_type,
911            format,
912            &FormatOptions::default(),
913        );
914    }
915
916    match *field_type {
917        Type::INT2 => encoder.encode_field_with_type_and_format(
918            &(value.as_i64().unwrap_or(0) as i16),
919            &Type::INT2,
920            format,
921            &FormatOptions::default(),
922        ),
923        Type::INT4 => encoder.encode_field_with_type_and_format(
924            &(value.as_i64().unwrap_or(0) as i32),
925            &Type::INT4,
926            format,
927            &FormatOptions::default(),
928        ),
929        Type::INT8 => encoder.encode_field_with_type_and_format(
930            &value.as_i64().unwrap_or(0),
931            &Type::INT8,
932            format,
933            &FormatOptions::default(),
934        ),
935        Type::FLOAT4 => encoder.encode_field_with_type_and_format(
936            &(value.as_f64().unwrap_or(0.0) as f32),
937            &Type::FLOAT4,
938            format,
939            &FormatOptions::default(),
940        ),
941        Type::FLOAT8 => encoder.encode_field_with_type_and_format(
942            &value.as_f64().unwrap_or(0.0),
943            &Type::FLOAT8,
944            format,
945            &FormatOptions::default(),
946        ),
947        Type::BOOL => encoder.encode_field_with_type_and_format(
948            &value.as_bool().unwrap_or(false),
949            &Type::BOOL,
950            format,
951            &FormatOptions::default(),
952        ),
953        _ => {
954            let value_str = json_value_to_string(value);
955            encoder.encode_field_with_type_and_format(
956                &value_str.as_str(),
957                field_type,
958                format,
959                &FormatOptions::default(),
960            )
961        }
962    }
963}
964
965#[async_trait::async_trait]
966impl SimpleQueryHandler for PostgresqlHandler {
967    async fn do_query<C>(&self, _client: &mut C, query: &str) -> PgWireResult<Vec<Response>>
968    where
969        C: ClientInfo + Unpin + Send + Sync,
970    {
971        match self.resolve(query).await? {
972            // The simple query protocol has no format negotiation: every cell is text.
973            PgOutcome::Rows { columns, values } => {
974                let fields = fields_for(&columns, |_| FieldFormat::Text);
975                let rows = encode_rows(&fields, &values);
976                Ok(vec![Response::Query(QueryResponse::new(
977                    fields,
978                    futures::stream::iter(rows),
979                ))])
980            }
981            PgOutcome::Tag(tag) => Ok(vec![Response::Execution(Tag::new(&tag))]),
982            PgOutcome::Close => Err(terminating_error()),
983        }
984    }
985}
986
987#[async_trait::async_trait]
988impl ExtendedQueryHandler for PostgresqlHandler {
989    type Statement = String;
990    type QueryParser = PostgresqlQueryParser;
991
992    fn query_parser(&self) -> Arc<Self::QueryParser> {
993        Arc::new(PostgresqlQueryParser)
994    }
995
996    async fn do_query<C>(
997        &self,
998        _client: &mut C,
999        portal: &Portal<Self::Statement>,
1000        _max_rows: usize,
1001    ) -> PgWireResult<Response>
1002    where
1003        C: ClientInfo + Unpin + Send + Sync,
1004    {
1005        let sql = &portal.statement.statement;
1006
1007        match self.take_or_resolve(sql).await? {
1008            // Encode each cell in the format this portal's Bind asked for - binary, for every
1009            // mainstream driver, on every column whose type has a binary representation.
1010            PgOutcome::Rows { columns, values } => {
1011                let fields = fields_for(&columns, |idx| {
1012                    format_for(&portal.result_column_format, idx, columns.len())
1013                });
1014                let rows = encode_rows(&fields, &values);
1015                Ok(Response::Query(QueryResponse::new(
1016                    fields,
1017                    futures::stream::iter(rows),
1018                )))
1019            }
1020            PgOutcome::Tag(tag) => Ok(Response::Execution(Tag::new(&tag))),
1021            PgOutcome::Close => Err(terminating_error()),
1022        }
1023    }
1024
1025    async fn do_describe_statement<C>(
1026        &self,
1027        _client: &mut C,
1028        stmt: &StoredStatement<Self::Statement>,
1029    ) -> PgWireResult<DescribeStatementResponse>
1030    where
1031        C: ClientInfo + Unpin + Send + Sync,
1032    {
1033        // The schema is whatever the LLM returns, so it has to be resolved here rather than
1034        // guessed. Previously this returned an unconditional empty field list, which told
1035        // every extended-protocol client that the statement produced zero columns and then
1036        // sent it data rows anyway.
1037        // Describing a *statement* happens before any Bind, so no result format has been
1038        // chosen yet. PostgreSQL sends format code 0 (text) in that RowDescription; the
1039        // portal's own Describe, below, carries the real one.
1040        let fields = self
1041            .resolve_for_describe(&stmt.statement, |_, _| FieldFormat::Text)
1042            .await?;
1043        Ok(DescribeStatementResponse::new(vec![], fields))
1044    }
1045
1046    async fn do_describe_portal<C>(
1047        &self,
1048        _client: &mut C,
1049        portal: &Portal<Self::Statement>,
1050    ) -> PgWireResult<DescribePortalResponse>
1051    where
1052        C: ClientInfo + Unpin + Send + Sync,
1053    {
1054        let fields = self
1055            .resolve_for_describe(&portal.statement.statement, |idx, columns| {
1056                format_for(&portal.result_column_format, idx, columns)
1057            })
1058            .await?;
1059        Ok(DescribePortalResponse::new(fields))
1060    }
1061}
1062
1063/// Query parser for PostgreSQL
1064pub struct PostgresqlQueryParser;
1065
1066#[async_trait::async_trait]
1067impl pgwire::api::stmt::QueryParser for PostgresqlQueryParser {
1068    type Statement = String;
1069
1070    async fn parse_sql<C>(
1071        &self,
1072        _client: &C,
1073        sql: &str,
1074        _types: &[Type],
1075    ) -> PgWireResult<Self::Statement>
1076    where
1077        C: ClientInfo + Unpin + Send + Sync,
1078    {
1079        Ok(sql.to_string())
1080    }
1081}
1082
1083/// Convert JSON value to string representation
1084fn json_value_to_string(value: &serde_json::Value) -> String {
1085    match value {
1086        serde_json::Value::Null => "".to_string(),
1087        serde_json::Value::Bool(b) => if *b { "t" } else { "f" }.to_string(),
1088        serde_json::Value::Number(n) => n.to_string(),
1089        serde_json::Value::String(s) => s.clone(),
1090        serde_json::Value::Array(_) | serde_json::Value::Object(_) => value.to_string(),
1091    }
1092}