Files
synapse/rust/src/database/postgres/connection.rs
T
Erik JohnstonandClaude Opus 4.8 2e6fdbbfee Add Postgres Connection/Cursor types and connect()
Add the Python-facing `Connection` / `Cursor` pair and the `connect`
factory, implementing enough of the PEP-249 (DBAPI2) shape for Synapse's
needs.

A `Connection` owns a single `tokio_postgres::Client`. The client *moves*
between the connection and an in-flight cursor rather than being shared:
it lives in the connection between interactions, is taken out for the
duration of a cursor, and is handed back when the cursor finishes. That
single-owner baton (an `Option<Client>` slot on each side) makes it
structurally impossible to use the connection mid-transaction or to run
two overlapping transactions on one socket — both become a clean "already
closed" error.

The transaction lifecycle (`cursor` opens with `BEGIN`; `finish`
COMMIT/ROLLBACKs and hands the client back; `CursorGuard` rolls back an
abandoned transaction on drop) is included here. On any
transaction-control error the client is dropped rather than returned,
closing the socket — safer than handing a possibly-broken connection back
to what will become a connection pool.

`connect()` parses a libpq-style DSN, blocks until connected, and spawns
the long-lived connection task onto the shared runtime (the libpq
default-host fixup is a follow-up). The cursor query methods
(`execute` / `fetch_one` / `fetch_all` / `fetch_next_batch` / `rowcount`)
delegate to the `CursorQueryState` machine.

`run_interaction` — the high-level glue that opens a cursor, runs the
callback, and commits/rolls back — follows in the next change, so
`cursor`/`finish`/`CursorGuard` carry a transitional `#[allow(dead_code)]`
until then. Now that the value/helpers/cursor_state modules are consumed
internally, their visibility is tightened from `pub` back to private.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-11 09:04:57 +00:00

434 lines
17 KiB
Rust

//! The Python-facing [`Connection`] / [`Cursor`] pair for the Postgres backend.
//!
//! These implement enough of the PEP-249 (DBAPI2) shape for Synapse's needs,
//! arranged so that the existing Python transaction driver
//! (`synapse.storage.database.new_transaction`) can drive them *unchanged*: it
//! opens a [`Cursor`] with [`Connection::cursor`], runs the interaction
//! function against it, and then commits or rolls back at the **connection**
//! level with [`Connection::commit`] / [`Connection::rollback`].
//!
//! The driver underneath is async but the Python API is sync, so every database
//! call is driven to completion on the shared tokio runtime via the `block_on`
//! helpers (see [`super::helpers::BlockingPostgresResult`]).
//!
//! ## Connection owns the transaction; the cursor is a thin view
//!
//! The single [`tokio_postgres::Client`] lives in the [`Connection`] for the
//! whole life of the connection. Transaction control (`BEGIN` / `COMMIT` /
//! `ROLLBACK`) is issued *on the connection*, because that is where Synapse's
//! driver issues it — `conn.commit()` runs while the cursor that produced the
//! rows is still open (the driver only closes the cursor afterwards).
//!
//! A [`Cursor`] is therefore cheap: it holds a clone of the owning
//! [`Connection`] (an `Arc`) plus its own result-set state
//! ([`CursorQueryState`]). It borrows the client only for the brief moment it
//! takes to *start* a query (`prepare` + `query_raw`); the resulting row stream
//! is self-contained (`'static`), so once a query has been issued the cursor
//! reads rows from its own state without touching the connection again. Many
//! cursors can share one connection this way, though in practice Synapse's
//! driver uses one at a time.
//!
//! ## Implicit transactions (matching psycopg2)
//!
//! psycopg2 is transactional by default: the first statement after `connect`
//! (or after a `commit`/`rollback`) implicitly opens a transaction.
//! `tokio_postgres`, by contrast, is autocommit by default. To behave like
//! psycopg2 we track whether a transaction is open (`in_txn`) and lazily issue a
//! `BEGIN` before the first statement of each transaction — unless the
//! connection has been put into autocommit mode (see [`Connection::set_autocommit`]).
//! `commit`/`rollback` end the transaction and clear the flag; with no
//! transaction open they are no-ops, just like psycopg2.
//!
//! ## Dropping the `Client` on error
//!
//! A *query* error (bad SQL, a constraint violation, an integer out of range,
//! …) leaves the connection open with its transaction in the aborted state,
//! exactly as psycopg2 does: the error propagates to Python and the driver is
//! expected to `rollback()`. We do **not** throw the connection away for these.
//!
//! The transaction-control statements are different. If `COMMIT` or `ROLLBACK`
//! itself fails we no longer know what state the server-side session is in, so
//! we drop the `Client` (closing the socket) rather than hand a possibly-broken
//! connection back for reuse. Likewise, [`Connection::close`] drops the client;
//! the server rolls back any transaction left open when the socket closes.
use std::sync::{Arc, Mutex, MutexGuard, TryLockError};
use pyo3::{
exceptions::PyRuntimeError,
prelude::*,
types::{PyInt, PyTuple},
};
use tokio_postgres::Client;
use crate::database::postgres::{
cursor_state::CursorQueryState, helpers::BlockingPostgresResult, value::PgValue,
};
/// `try_lock` a mutex that is single-threaded by contract, mapping its two
/// failure modes to Python errors.
///
/// A poisoned mutex means a panic happened while it was held, so the guarded
/// state can't be trusted: `on_poison` resets it (closing a connection /
/// discarding a result set) before we error. `WouldBlock` means the object is
/// being used from two threads at once, which is a caller bug rather than
/// something to block on. `noun` names the object in both messages (e.g.
/// `"connection"`, `"cursor"`).
fn try_lock_or_reset<'a, T>(
mutex: &'a Mutex<T>,
noun: &str,
on_poison: impl FnOnce(&mut T),
) -> PyResult<MutexGuard<'a, T>> {
match mutex.try_lock() {
Ok(guard) => Ok(guard),
Err(TryLockError::Poisoned(poisoned)) => {
on_poison(&mut poisoned.into_inner());
Err(PyRuntimeError::new_err(format!("{noun} mutex poisoned")))
}
Err(TryLockError::WouldBlock) => Err(PyRuntimeError::new_err(format!(
"{noun} is being used in another thread and cannot be used concurrently"
))),
}
}
/// A single Postgres connection exposed to Python.
///
/// Owns the [`tokio_postgres::Client`] for its whole life and is the authority
/// on transaction state. The `Arc<Mutex<...>>` lets cursors hold a cheap clone
/// (so they can reach the client to start a query) while keeping all access to
/// the client serialised.
#[pyclass(frozen, skip_from_py_object)]
#[derive(Clone)]
pub struct Connection {
inner: Arc<Mutex<ConnInner>>,
}
/// The mutable guts of a [`Connection`], behind its mutex.
struct ConnInner {
/// The driver client. `None` once the connection has been closed (or thrown
/// away after a transaction-control error); any further use is an error.
client: Option<Client>,
/// Whether a transaction is currently open (a `BEGIN` has been issued and
/// not yet matched by a `COMMIT`/`ROLLBACK`). Drives the lazy `BEGIN`.
in_txn: bool,
/// In autocommit mode we never issue an implicit `BEGIN`, so each statement
/// runs in its own implicit transaction. Defaults to `false`, matching
/// psycopg2's transactional default.
autocommit: bool,
}
impl Connection {
/// Wrap a freshly-established `Client` in a `Connection`.
pub fn new(client: Client) -> Self {
Self {
inner: Arc::new(Mutex::new(ConnInner {
client: Some(client),
in_txn: false,
autocommit: false,
})),
}
}
/// Lock the inner state.
///
/// Uses `try_lock` rather than `lock`: a connection is used from a single
/// thread at a time by contract (Synapse hands one connection to one
/// worker thread), so contention means it's being used from two threads at
/// once, which we surface as an error instead of blocking. A poisoned mutex
/// (a panic happened mid-operation) closes the connection — we no longer
/// know the session state — and errors.
fn lock(&self) -> PyResult<MutexGuard<'_, ConnInner>> {
try_lock_or_reset(&self.inner, "connection", |inner| {
// On poison we no longer know the session state, so close the
// connection: drop the client and clear the transaction flag.
inner.client = None;
inner.in_txn = false;
})
}
/// Borrow the client just long enough to run `f` (typically starting a
/// query), opening an implicit transaction first if one isn't already open.
///
/// The borrow ends as soon as `f` returns; `f` is expected to hand back an
/// owned, self-contained value (e.g. a `'static` row stream) rather than
/// anything tied to the client. Errors if the connection is closed.
fn with_client<R>(
&self,
py: Python<'_>,
f: impl FnOnce(&Client) -> PyResult<R>,
) -> PyResult<R> {
let mut guard = self.lock()?;
// Lazily open a transaction so statements are transactional by default,
// matching psycopg2. We set `in_txn` *after* a successful `BEGIN` but
// before running `f`, so that if `f` (the user's statement) fails the
// open-but-aborted transaction is still tracked and `rollback()` knows
// to clean it up.
if !guard.autocommit && !guard.in_txn {
{
let client = client_ref(&guard)?;
client.execute("BEGIN", &[]).block_on_result(py)?;
}
guard.in_txn = true;
}
let client = client_ref(&guard)?;
f(client)
}
/// Issue a transaction-control statement (`COMMIT`/`ROLLBACK`) if a
/// transaction is open; a no-op otherwise.
///
/// On success the transaction flag is cleared. On failure the client is
/// dropped (closing the socket): after a failed commit/rollback the session
/// state is unknown, so the connection is thrown away rather than reused.
fn end_txn(&self, py: Python<'_>, stmt: &'static str) -> PyResult<()> {
let mut guard = self.lock()?;
if !guard.in_txn {
return Ok(());
}
// If the client is already gone the server has rolled the transaction
// back for us; just clear the flag.
if guard.client.is_none() {
guard.in_txn = false;
return Ok(());
}
let result = {
let client = client_ref(&guard)?;
client.execute(stmt, &[]).block_on_result(py)
};
match result {
Ok(_) => {
guard.in_txn = false;
Ok(())
}
Err(err) => {
// Unknown session state: drop the connection rather than reuse it.
guard.client = None;
guard.in_txn = false;
Err(err)
}
}
}
}
/// Borrow the live client out of a locked inner state, or error if the
/// connection has been closed.
fn client_ref(guard: &ConnInner) -> PyResult<&Client> {
guard
.client
.as_ref()
.ok_or_else(|| PyRuntimeError::new_err("connection already closed"))
}
#[pymethods]
impl Connection {
/// Open a new cursor over this connection.
///
/// Cheap: no I/O and no `BEGIN` happens here (the transaction is opened
/// lazily on the first `execute`). The returned cursor shares this
/// connection's client.
fn cursor(&self) -> Cursor {
Cursor::new(self.clone())
}
/// Commit the current transaction, if one is open. A no-op otherwise.
fn commit(&self, py: Python<'_>) -> PyResult<()> {
self.end_txn(py, "COMMIT")
}
/// Roll back the current transaction, if one is open. A no-op otherwise.
fn rollback(&self, py: Python<'_>) -> PyResult<()> {
self.end_txn(py, "ROLLBACK")
}
/// Close the connection, dropping the underlying client.
///
/// Dropping the client closes the socket; the server rolls back any
/// transaction that was still open. Idempotent: closing an
/// already-closed connection is fine.
fn close(&self) -> PyResult<()> {
let mut guard = self.lock()?;
guard.client = None;
guard.in_txn = false;
Ok(())
}
/// Switch autocommit mode on or off.
///
/// In autocommit mode no implicit `BEGIN` is issued, so each statement runs
/// in its own transaction. Mirrors psycopg2's `set_session(autocommit=...)`,
/// including its rule that the mode can't be changed while a transaction is
/// in progress.
fn set_autocommit(&self, autocommit: bool) -> PyResult<()> {
let mut guard = self.lock()?;
if guard.in_txn {
return Err(PyRuntimeError::new_err(
"cannot change autocommit mode while a transaction is in progress",
));
}
guard.autocommit = autocommit;
Ok(())
}
/// Context-manager entry: returns the connection itself.
fn __enter__(slf: Py<Self>) -> Py<Self> {
slf
}
/// Context-manager exit: commit if the block completed normally, roll back
/// if it raised. Like psycopg2, this does *not* close the connection, and
/// it does not suppress the exception (returns `False`).
fn __exit__(
&self,
py: Python<'_>,
exc_type: Option<Bound<'_, PyAny>>,
_exc_value: Option<Bound<'_, PyAny>>,
_traceback: Option<Bound<'_, PyAny>>,
) -> PyResult<bool> {
if exc_type.is_some() {
self.rollback(py)?;
} else {
self.commit(py)?;
}
Ok(false)
}
}
/// A PEP-249-style cursor over a connection's current transaction.
///
/// Cheap to create: it holds a clone of the owning [`Connection`] plus its own
/// result-set state ([`CursorQueryState`]). Transaction control lives on the
/// connection, not here. All interior state is behind a mutex so the cursor can
/// be `frozen` (shared via `Arc`) yet still mutated by its methods.
#[pyclass(frozen, skip_from_py_object)]
#[derive(Clone)]
pub struct Cursor {
/// The owning connection, used to reach the client when starting a query.
connection: Connection,
/// State of the most recent `execute` (live row stream, rowcount, etc.).
state: Arc<Mutex<CursorQueryState>>,
}
impl Cursor {
/// Build a cursor over `connection`.
fn new(connection: Connection) -> Self {
Self {
connection,
state: Arc::new(Mutex::new(CursorQueryState::new())),
}
}
/// Lock the cursor's query state.
///
/// Like [`Connection::lock`], uses `try_lock`: a cursor is single-threaded
/// by contract, so contention means concurrent use, which we surface as an
/// error. On a poisoned mutex we drop the result set (resetting to `Idle`)
/// and error.
fn lock_state(&self) -> PyResult<MutexGuard<'_, CursorQueryState>> {
// On poison we drop the (untrusted) result set, resetting to `Idle`.
try_lock_or_reset(&self.state, "cursor", |state| {
*state = CursorQueryState::new()
})
}
}
#[pymethods]
impl Cursor {
/// Execute `query`, optionally with positional `params` bound to `$1`,
/// `$2`, ... placeholders.
///
/// Any previous result set is discarded. After this returns, rows (if any)
/// can be read with `fetch_one`/`fetch_all`/`fetch_next_batch`.
#[pyo3(signature = (query, params = None))]
fn execute(&self, py: Python<'_>, query: &str, params: Option<Vec<PgValue>>) -> PyResult<()> {
// Drop any previous result set before starting the new query.
self.lock_state()?.new_query();
// Borrow the connection's client only for as long as it takes to start
// the query. `query_raw` returns a `'static` `RowStream`, so the borrow
// ends here and the cursor owns the stream from now on.
let (stream, description) = self.connection.with_client(py, |client| {
let statement = client.prepare(query).block_on_result(py)?;
let stream = client
.query_raw(&statement, params.unwrap_or_default())
.block_on_result(py)?;
// The column names back the (future) PEP-249 `Cursor.description`;
// pull them out of the prepared statement here so `cursor_state`
// stays decoupled from `tokio_postgres::Column`.
let description = statement
.columns()
.iter()
.map(|c| c.name().to_string())
.collect::<Vec<_>>();
Ok((stream, description))
})?;
// A statement with no result columns (INSERT/UPDATE/DELETE/DDL without
// RETURNING) produces no rows for anyone to fetch, so nothing would
// otherwise poll its stream — and a `query_raw` stream only reports the
// server's response, including any error (e.g. a constraint violation)
// and the affected-row count, once polled. Drive it to completion now so
// such errors surface here at `execute` time (as psycopg2 does) rather
// than being lost when the result is never fetched — most visibly under
// autocommit, where there is no later `commit` to surface them.
let returns_rows = !description.is_empty();
let mut state = self.lock_state()?;
state.on_query_start(stream, description);
if !returns_rows {
state.finish_no_rows(py)?;
}
Ok(())
}
/// Return the next row of the current result set, or `None` if exhausted.
fn fetch_one<'py>(&self, py: Python<'py>) -> PyResult<Option<Bound<'py, PyTuple>>> {
self.lock_state()?.fetch_one(py)
}
/// Drain and return all remaining rows of the current result set.
fn fetch_all<'py>(&self, py: Python<'py>) -> PyResult<Vec<Bound<'py, PyTuple>>> {
self.lock_state()?.fetch_all(py)
}
/// Fetch the next batch of rows from the current result set.
///
/// Blocks for the first row, then returns any further rows that are already
/// available without blocking. Returns an empty list only once the result
/// set is exhausted. `capacity` is a hint for the size of the returned
/// buffer, not a limit on the number of rows returned.
#[pyo3(signature = (capacity = 100))]
fn fetch_next_batch<'py>(
&self,
py: Python<'py>,
capacity: usize,
) -> PyResult<Vec<Bound<'py, PyTuple>>> {
self.lock_state()?.fetch_next_batch(py, capacity)
}
/// Return the PEP-249 `rowcount` for the last statement.
///
/// This is the number of rows affected by a DML statement; for queries
/// where it isn't (yet) known it follows PEP-249 and returns `-1`.
fn rowcount<'py>(&self, py: Python<'py>) -> PyResult<Bound<'py, PyInt>> {
self.lock_state()?.rowcount(py)
}
/// Close the cursor, discarding any in-flight result set.
///
/// This does *not* touch the transaction — that's the connection's job.
/// Idempotent.
fn close(&self) -> PyResult<()> {
*self.lock_state()? = CursorQueryState::new();
Ok(())
}
}