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}