Files
synapse/tests/synapse_rust/test_database_postgres.py
T
c159abd149 Add Cursor.executemany to the Rust Postgres backend
Implement DBAPI2's `executemany`, which Synapse uses for batched writes.
The statement is prepared once and run for each parameter set inside the
connection's (lazily opened) transaction, so a failure part-way aborts
the whole batch. The per-set executions are pipelined — their futures
are driven concurrently so tokio_postgres streams the batch onto the
connection in one round-trip rather than one per statement.

Afterwards `rowcount` reports the total rows affected across all
executions (as psycopg2 does) via a new fetchless "command complete"
cursor state; there is no result set to fetch or describe. An empty
parameter sequence is a no-op that leaves rowcount at -1.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-24 12:42:05 +01:00

1148 lines
45 KiB
Python

# This file is licensed under the Affero General Public License (AGPL) version 3.
#
# Copyright (C) 2026 Element Creations Ltd
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as
# published by the Free Software Foundation, either version 3 of the
# License, or (at your option) any later version.
#
# See the GNU Affero General Public License for more details:
# <https://www.gnu.org/licenses/agpl-3.0.html>.
"""Tests for the Rust-implemented, DBAPI2-shaped Postgres ``Connection`` /
``Cursor`` pair exposed as ``synapse.synapse_rust.database.postgres``.
These exercise the real ``tokio-postgres`` backend, so they require a live
Postgres server and are skipped unless the test suite is configured to run
against Postgres (i.e. ``SYNAPSE_POSTGRES`` is set, the same switch used by the
rest of the suite).
"""
import logging
from typing import Any, Optional, cast
from twisted.internet import reactor
from synapse.synapse_rust.database import postgres
from synapse.types import ISynapseReactor
from tests import unittest
from tests.utils import (
POSTGRES_BASE_DB,
POSTGRES_HOST,
POSTGRES_PASSWORD,
POSTGRES_PORT,
POSTGRES_USER,
USE_POSTGRES_FOR_TESTS,
)
def _connect(dsn: str) -> Any:
"""Open a Rust connection, using the global reactor's shared tokio runtime.
The backend takes its runtime handle from the reactor (see the Rust
``connect``); it is started on demand, so this works under trial even though
the reactor itself isn't running during a synchronous test.
"""
return postgres.connect(cast(ISynapseReactor, reactor), dsn)
def _build_dsn() -> str:
"""Build a libpq keyword/value connection string from the test config."""
parts = [f"dbname={POSTGRES_BASE_DB}"]
if POSTGRES_USER is not None:
parts.append(f"user={POSTGRES_USER}")
if POSTGRES_HOST is not None:
parts.append(f"host={POSTGRES_HOST}")
if POSTGRES_PORT is not None:
parts.append(f"port={POSTGRES_PORT}")
if POSTGRES_PASSWORD is not None:
parts.append(f"password={POSTGRES_PASSWORD}")
return " ".join(parts)
def run_interaction(conn: Any, func: Any, *args: Any, **kwargs: Any) -> Any:
"""Run ``func(cursor, *args, **kwargs)`` in a transaction on ``conn``:
commit on success, roll back if it raises.
The shape Synapse's ``new_transaction`` drives the shim with, as a test
harness. (The shim used to expose this itself as
``Connection.run_interaction``, but nothing in production called it.)
The commit sits inside the protected region — as in
``RustConnectionPool._in_transaction`` — so a failed commit also rolls
back; a rollback failure is logged rather than allowed to mask the
original exception.
"""
cursor = conn.cursor()
try:
result = func(cursor, *args, **kwargs)
conn.commit()
return result
except BaseException:
try:
conn.rollback()
except Exception:
logging.getLogger(__name__).warning(
"Rollback failed in test run_interaction", exc_info=True
)
raise
@unittest.skip_unless(
bool(USE_POSTGRES_FOR_TESTS), "requires a Postgres server (set SYNAPSE_POSTGRES)"
)
class PostgresConnectionTestCase(unittest.TestCase):
"""Tests for the Rust Postgres ``Connection`` / ``Cursor``."""
def setUp(self) -> None:
self.conn = _connect(_build_dsn())
def tearDown(self) -> None:
# Explicitly drop the connection to ensure that the underlying Rust
# object is dropped before the Python interpreter shuts down. Otherwise,
# the open connection will block us tearing down the test database.
del self.conn
# ------------------------------------------------------------------
# connect()
# ------------------------------------------------------------------
def test_connect_bad_dsn_raises(self) -> None:
# A syntactically valid but unconnectable DSN should raise one of our
# DBAPI2 errors rather than return a half-open connection.
with self.assertRaises(postgres.Error):
_connect("host=127.0.0.1 port=1 dbname=does_not_exist")
# ------------------------------------------------------------------
# execute() / fetch_one() / fetch_all()
# ------------------------------------------------------------------
def test_fetch_one(self) -> None:
def interaction(cursor: Any) -> Optional[list[Any]]:
cursor.execute("SELECT 42::int, 'hello'::text")
return cursor.fetch_one()
self.assertEqual(run_interaction(self.conn, interaction), (42, "hello"))
def test_fetch_one_returns_none_when_exhausted(self) -> None:
def interaction(cursor: Any) -> list[Any]:
cursor.execute("SELECT 1 WHERE true")
first = cursor.fetch_one()
second = cursor.fetch_one()
return [first, second]
self.assertEqual(run_interaction(self.conn, interaction), [(1,), None])
def test_fetch_all(self) -> None:
def interaction(cursor: Any) -> list[list[Any]]:
cursor.execute(
"""
SELECT * FROM (VALUES (1, 'a'), (2, 'b'), (3, 'c'))
AS v(id, name) ORDER BY id
"""
)
return cursor.fetch_all()
self.assertEqual(
run_interaction(self.conn, interaction),
[(1, "a"), (2, "b"), (3, "c")],
)
def test_fetch_all_empty(self) -> None:
def interaction(cursor: Any) -> list[list[Any]]:
cursor.execute("SELECT 1 WHERE false")
return cursor.fetch_all()
self.assertEqual(run_interaction(self.conn, interaction), [])
def test_fetch_without_query_raises(self) -> None:
"""Calling fetch before execute is an error, not a silent empty result."""
def interaction(cursor: Any) -> None:
cursor.fetch_one()
with self.assertRaises(RuntimeError):
run_interaction(self.conn, interaction)
def test_reuse_cursor_for_multiple_queries(self) -> None:
"""A single cursor can run several queries in sequence."""
def interaction(cursor: Any) -> list[Any]:
results = []
for n in (1, 2, 3):
cursor.execute("SELECT $1::int", [n])
row = cursor.fetch_one()
assert row is not None
results.append(row[0])
return results
self.assertEqual(run_interaction(self.conn, interaction), [1, 2, 3])
# ------------------------------------------------------------------
# executemany()
# ------------------------------------------------------------------
def test_executemany_runs_once_per_param_set(self) -> None:
"""executemany applies the statement for each parameter set."""
def interaction(cursor: Any) -> list[Any]:
cursor.execute("CREATE TEMP TABLE em (id int, name text)")
cursor.executemany(
"INSERT INTO em (id, name) VALUES ($1, $2)",
[[1, "a"], [2, "b"], [3, "c"]],
)
cursor.execute("SELECT id, name FROM em ORDER BY id")
return cursor.fetch_all()
self.assertEqual(
run_interaction(self.conn, interaction),
[(1, "a"), (2, "b"), (3, "c")],
)
def test_executemany_rowcount_is_total_affected(self) -> None:
"""rowcount after executemany is the sum across all executions."""
def interaction(cursor: Any) -> int:
cursor.execute("CREATE TEMP TABLE em (id int)")
cursor.executemany("INSERT INTO em VALUES ($1)", [[1], [2], [3]])
return cursor.rowcount()
self.assertEqual(run_interaction(self.conn, interaction), 3)
def test_executemany_leaves_no_result_set(self) -> None:
"""executemany produces nothing to describe."""
def describe(cursor: Any) -> Any:
cursor.execute("CREATE TEMP TABLE em (id int)")
cursor.executemany("INSERT INTO em VALUES ($1)", [[1], [2]])
return cursor.description()
self.assertIsNone(run_interaction(self.conn, describe))
def test_executemany_fetch_after_raises(self) -> None:
"""There is no result set after executemany, so fetching is an error."""
def interaction(cursor: Any) -> Any:
cursor.execute("CREATE TEMP TABLE em (id int)")
cursor.executemany("INSERT INTO em VALUES ($1)", [[1], [2]])
cursor.fetch_one()
with self.assertRaises(RuntimeError):
run_interaction(self.conn, interaction)
def test_executemany_empty_is_noop(self) -> None:
"""An empty parameter sequence runs nothing and affects no rows."""
def interaction(cursor: Any) -> int:
cursor.execute("CREATE TEMP TABLE em (id int)")
cursor.executemany("INSERT INTO em VALUES ($1)", [])
# Nothing ran, so rowcount is the "unknown" sentinel...
self.assertEqual(cursor.rowcount(), -1)
# ...and the table is untouched.
cursor.execute("SELECT count(*) FROM em")
row = cursor.fetch_one()
assert row is not None
return row[0]
self.assertEqual(run_interaction(self.conn, interaction), 0)
def test_executemany_rolls_back_on_error(self) -> None:
"""A failure part-way through executemany aborts the transaction, so
no rows from the batch survive."""
table = "rust_pg_test_executemany_rollback"
try:
def interaction(cursor: Any) -> None:
cursor.execute(f"CREATE TABLE {table} (id int PRIMARY KEY)")
# The third set duplicates the first, violating the primary key.
cursor.executemany(
f"INSERT INTO {table} VALUES ($1)",
[[1], [2], [1]],
)
with self.assertRaises(postgres.IntegrityError):
run_interaction(self.conn, interaction)
# The CREATE TABLE and the whole batch were in one transaction that
# rolled back, so the table should not exist.
def table_exists(cursor: Any) -> Any:
cursor.execute("SELECT to_regclass($1)::text", [table])
row = cursor.fetch_one()
assert row is not None
return row[0]
self.assertIsNone(run_interaction(self.conn, table_exists))
finally:
run_interaction(
self.conn,
lambda cursor: cursor.execute(f"DROP TABLE IF EXISTS {table}"),
)
# ------------------------------------------------------------------
# fetch_next_batch()
# ------------------------------------------------------------------
def test_fetch_next_batch_returns_first_row(self) -> None:
"""A non-empty result set yields a batch containing at least the first
row, which blocks until available."""
def interaction(cursor: Any) -> list[Any]:
cursor.execute("SELECT 42::int, 'hello'::text")
return cursor.fetch_next_batch()
# The batch must be non-empty and start with the first row. We don't
# assert the exact length: how many further rows are already buffered
# (and so returned without blocking) is timing-dependent.
batch = run_interaction(self.conn, interaction)
self.assertEqual(batch[0], (42, "hello"))
def test_fetch_next_batch_empty_when_no_rows(self) -> None:
"""An empty result set yields an empty batch."""
def interaction(cursor: Any) -> list[Any]:
cursor.execute("SELECT 1 WHERE false")
return cursor.fetch_next_batch()
self.assertEqual(run_interaction(self.conn, interaction), [])
def test_fetch_next_batch_collects_all_rows_across_batches(self) -> None:
"""Looping until an empty batch is returned yields every row exactly
once, in order, regardless of how the rows are split across batches."""
def interaction(cursor: Any) -> list[Any]:
cursor.execute(
"""
SELECT id FROM generate_series(1, 1000) AS s(id) ORDER BY id
"""
)
rows = []
while True:
batch = cursor.fetch_next_batch()
if not batch:
break
rows.extend(batch)
return rows
self.assertEqual(
run_interaction(self.conn, interaction),
[(n,) for n in range(1, 1001)],
)
def test_fetch_next_batch_reports_exhaustion_with_empty_batch(self) -> None:
"""After the last rows, one further call returns an empty batch to
report exhaustion."""
def interaction(cursor: Any) -> list[Any]:
cursor.execute("SELECT 1")
first = cursor.fetch_next_batch()
second = cursor.fetch_next_batch()
return [first, second]
first, second = run_interaction(self.conn, interaction)
self.assertEqual(first, [(1,)])
self.assertEqual(second, [])
def test_fetch_next_batch_capacity_is_not_a_limit(self) -> None:
"""`capacity` is only a buffer hint; a batch may exceed it."""
def interaction(cursor: Any) -> list[Any]:
cursor.execute(
"SELECT id FROM generate_series(1, 100) AS s(id) ORDER BY id"
)
rows = []
while True:
batch = cursor.fetch_next_batch(1)
if not batch:
break
rows.extend(batch)
return rows
self.assertEqual(
run_interaction(self.conn, interaction),
[(n,) for n in range(1, 101)],
)
def test_fetch_next_batch_without_query_raises(self) -> None:
"""Calling fetch_next_batch before execute is an error."""
def interaction(cursor: Any) -> None:
cursor.fetch_next_batch()
with self.assertRaises(RuntimeError):
run_interaction(self.conn, interaction)
def test_fetch_next_batch_interleaves_with_fetch_one(self) -> None:
"""fetch_one and fetch_next_batch share the same underlying stream, so
rows already consumed by one are not seen by the other."""
def interaction(cursor: Any) -> list[Any]:
cursor.execute("SELECT id FROM generate_series(1, 10) AS s(id) ORDER BY id")
first = cursor.fetch_one()
rows = [first]
while True:
batch = cursor.fetch_next_batch()
if not batch:
break
rows.extend(batch)
return rows
self.assertEqual(
run_interaction(self.conn, interaction),
[(n,) for n in range(1, 11)],
)
# ------------------------------------------------------------------
# Fetching after exhaustion is a programming error
# ------------------------------------------------------------------
#
# Once a fetch has *reported* exhaustion (fetch_one -> None,
# fetch_next_batch -> [], or fetch_all returning), the result set is spent.
# Fetching again indicates a bug in the caller, so it raises rather than
# silently returning nothing.
def test_fetch_one_after_exhaustion_raises(self) -> None:
"""A fetch_one after the one that returned None is an error."""
def interaction(cursor: Any) -> None:
cursor.execute("SELECT 1")
self.assertEqual(cursor.fetch_one(), (1,))
self.assertIsNone(cursor.fetch_one()) # reports exhaustion
cursor.fetch_one() # over-fetch
with self.assertRaises(RuntimeError):
run_interaction(self.conn, interaction)
def test_fetch_next_batch_after_exhaustion_raises(self) -> None:
"""A fetch_next_batch after the one that returned [] is an error."""
def interaction(cursor: Any) -> None:
cursor.execute("SELECT 1")
self.assertEqual(cursor.fetch_next_batch(), [(1,)])
self.assertEqual(cursor.fetch_next_batch(), []) # reports exhaustion
cursor.fetch_next_batch() # over-fetch
with self.assertRaises(RuntimeError):
run_interaction(self.conn, interaction)
def test_fetch_all_twice_raises(self) -> None:
"""fetch_all exhausts the result set, so a second fetch_all is an
error."""
def interaction(cursor: Any) -> None:
cursor.execute("SELECT 1")
self.assertEqual(cursor.fetch_all(), [(1,)])
cursor.fetch_all() # over-fetch
with self.assertRaises(RuntimeError):
run_interaction(self.conn, interaction)
def test_fetch_one_after_fetch_all_raises(self) -> None:
"""fetch_all reports exhaustion, so a following fetch_one is an
error rather than None."""
def interaction(cursor: Any) -> None:
cursor.execute("SELECT 1")
cursor.fetch_all()
cursor.fetch_one() # over-fetch
with self.assertRaises(RuntimeError):
run_interaction(self.conn, interaction)
def test_exhausted_error_is_distinct_from_no_query(self) -> None:
"""The exhausted-result error names exhaustion, not a missing query, so
the two programming errors are distinguishable."""
def interaction(cursor: Any) -> None:
cursor.execute("SELECT 1")
cursor.fetch_all()
cursor.fetch_one()
with self.assertRaisesRegex(RuntimeError, "exhausted"):
run_interaction(self.conn, interaction)
# ------------------------------------------------------------------
# Value round-tripping (ToSql + FromSql for each supported type)
# ------------------------------------------------------------------
def test_value_roundtrip_all_types(self) -> None:
"""Each supported type survives a param -> column -> row round trip."""
def interaction(cursor: Any) -> Optional[list[Any]]:
cursor.execute(
"""
CREATE TEMP TABLE types_roundtrip (
c_bool bool,
c_int2 smallint,
c_int4 int,
c_int8 bigint,
c_float4 float4,
c_float8 float8,
c_text text,
c_varchar varchar,
c_bytea bytea
)
"""
)
cursor.execute(
"""
INSERT INTO types_roundtrip VALUES
($1, $2, $3, $4, $5, $6, $7, $8, $9)
""",
[
True,
7,
1234,
10**15,
1.5, # exactly representable as float4
3.5,
"hello",
"world",
b"\x00\x01\x02bytes",
],
)
cursor.execute(
"""
SELECT c_bool, c_int2, c_int4, c_int8, c_float4, c_float8,
c_text, c_varchar, c_bytea FROM types_roundtrip
"""
)
return cursor.fetch_one()
self.assertEqual(
run_interaction(self.conn, interaction),
(
True,
7,
1234,
10**15,
1.5,
3.5,
"hello",
"world",
b"\x00\x01\x02bytes",
),
)
def test_null_values_become_none(self) -> None:
def interaction(cursor: Any) -> Optional[list[Any]]:
cursor.execute("SELECT NULL::int, NULL::text, NULL::bool")
return cursor.fetch_one()
self.assertEqual(run_interaction(self.conn, interaction), (None, None, None))
def test_null_param(self) -> None:
def interaction(cursor: Any) -> Optional[list[Any]]:
cursor.execute("SELECT $1::text", [None])
return cursor.fetch_one()
self.assertEqual(run_interaction(self.conn, interaction), (None,))
def test_float4_precision_is_lossy(self) -> None:
"""float4 params are narrowed to f32; document the resulting precision."""
def interaction(cursor: Any) -> Optional[list[Any]]:
cursor.execute("SELECT $1::float4", [0.1])
return cursor.fetch_one()
row = run_interaction(self.conn, interaction)
assert row is not None
# 0.1 is not exactly representable in f32, so it comes back widened.
self.assertAlmostEqual(row[0], 0.1, places=6)
self.assertNotEqual(row[0], 0.1)
def test_unsupported_param_type_raises_type_error(self) -> None:
def interaction(cursor: Any) -> None:
cursor.execute("SELECT $1", [object()])
with self.assertRaises(TypeError):
run_interaction(self.conn, interaction)
def test_int_out_of_range_for_column_raises(self) -> None:
def interaction(cursor: Any) -> None:
# Bind the parameter to a genuine int4 column (rather than casting
# the *result*), so the value is encoded against INT4 and hits the
# range guard in `PgValue::to_sql` rather than some unrelated cast
# error. 10**12 is far too large for int4.
cursor.execute("CREATE TEMP TABLE oor (x int4)")
cursor.execute("INSERT INTO oor VALUES ($1)", [10**12])
# This is a client-side encoding error (no SQLSTATE), so it surfaces as
# a plain DatabaseError -- not an OperationalError, since retrying a
# deterministic bad value would be pointless.
with self.assertRaises(postgres.DatabaseError) as ctx:
run_interaction(self.conn, interaction)
self.assertNotIsInstance(ctx.exception, postgres.OperationalError)
# ------------------------------------------------------------------
# rowcount()
# ------------------------------------------------------------------
def test_rowcount_minus_one_before_query(self) -> None:
def interaction(cursor: Any) -> int:
return cursor.rowcount()
self.assertEqual(run_interaction(self.conn, interaction), -1)
def test_rowcount_for_dml(self) -> None:
def interaction(cursor: Any) -> list[int]:
cursor.execute("CREATE TEMP TABLE rc (id int)")
cursor.execute("INSERT INTO rc VALUES (1), (2), (3)")
inserted = cursor.rowcount()
cursor.execute("UPDATE rc SET id = id WHERE id > 1")
updated = cursor.rowcount()
cursor.execute("DELETE FROM rc")
deleted = cursor.rowcount()
return [inserted, updated, deleted]
self.assertEqual(run_interaction(self.conn, interaction), [3, 2, 3])
# ------------------------------------------------------------------
# description
# ------------------------------------------------------------------
def test_description_is_none_before_query(self) -> None:
"""A cursor that has not run a query has no description."""
def interaction(cursor: Any) -> Any:
return cursor.description()
self.assertIsNone(run_interaction(self.conn, interaction))
def test_description_reports_column_names(self) -> None:
"""A row-returning statement describes its columns; only the name is
populated, in a PEP-249 7-tuple."""
def interaction(cursor: Any) -> Any:
cursor.execute("SELECT 1 AS a, 'x'::text AS b")
return cursor.description()
description = run_interaction(self.conn, interaction)
self.assertEqual([col[0] for col in description], ["a", "b"])
# Each entry is a PEP-249 7-tuple with only the name populated.
for col in description:
self.assertEqual(len(col), 7)
self.assertTrue(all(field is None for field in col[1:]))
def test_description_available_after_fetch(self) -> None:
"""The description survives after the rows have been fetched."""
def interaction(cursor: Any) -> Any:
cursor.execute("SELECT 1 AS a")
cursor.fetch_all() # exhausts the result set
return cursor.description()
description = run_interaction(self.conn, interaction)
self.assertEqual([col[0] for col in description], ["a"])
def test_description_is_none_for_dml(self) -> None:
"""A statement that returns no rows (a bare INSERT) has no
description, matching psycopg2."""
def interaction(cursor: Any) -> Any:
cursor.execute("CREATE TEMP TABLE d (id int)")
cursor.execute("INSERT INTO d VALUES (1)")
return cursor.description()
self.assertIsNone(run_interaction(self.conn, interaction))
# ------------------------------------------------------------------
# Transaction handling (COMMIT on success, ROLLBACK on error)
# ------------------------------------------------------------------
def test_commit_persists_changes(self) -> None:
"""A successful interaction commits; changes are visible afterwards."""
table = "rust_pg_test_commit"
try:
def create(cursor: Any) -> None:
cursor.execute(f"CREATE TABLE {table} (id int)")
cursor.execute(f"INSERT INTO {table} VALUES (1), (2)")
run_interaction(self.conn, create)
def read(cursor: Any) -> list[list[Any]]:
cursor.execute(f"SELECT id FROM {table} ORDER BY id")
return cursor.fetch_all()
self.assertEqual(run_interaction(self.conn, read), [(1,), (2,)])
finally:
run_interaction(
self.conn,
lambda cursor: cursor.execute(f"DROP TABLE IF EXISTS {table}"),
)
def test_rollback_on_exception(self) -> None:
"""If the interaction raises, its changes are rolled back."""
table = "rust_pg_test_rollback"
try:
class MarkerError(Exception):
pass
def create_then_fail(cursor: Any) -> None:
cursor.execute(f"CREATE TABLE {table} (id int)")
raise MarkerError()
with self.assertRaises(MarkerError):
run_interaction(self.conn, create_then_fail)
# The table creation should have been rolled back, so the table
# must not exist.
def exists(cursor: Any) -> Optional[list[Any]]:
cursor.execute(
"""
SELECT count(*)::int FROM information_schema.tables
WHERE table_name = $1
""",
[table],
)
return cursor.fetch_one()
self.assertEqual(run_interaction(self.conn, exists), (0,))
finally:
run_interaction(
self.conn,
lambda cursor: cursor.execute(f"DROP TABLE IF EXISTS {table}"),
)
def test_connection_reusable_after_rolled_back_interaction(self) -> None:
"""A failed interaction returns the connection to a clean, usable state."""
class MarkerError(Exception):
pass
def fail(cursor: Any) -> None:
cursor.execute("SELECT 1")
raise MarkerError()
with self.assertRaises(MarkerError):
run_interaction(self.conn, fail)
# The connection should still work for a subsequent interaction.
def ok(cursor: Any) -> Optional[list[Any]]:
cursor.execute("SELECT 99::int")
return cursor.fetch_one()
self.assertEqual(run_interaction(self.conn, ok), (99,))
def test_connection_reusable_after_server_side_error(self) -> None:
"""A *server-rejected* statement rolls back, but leaves the connection
usable — distinct from the Python-exception rollback path above, since
the error originates in Postgres (during prepare/execute)."""
def bad_sql(cursor: Any) -> None:
# A syntax error: the server rejects this during prepare, which
# surfaces as a DatabaseError and rolls the transaction back.
cursor.execute("SELECT FROM WHERE not valid sql")
with self.assertRaises(postgres.DatabaseError):
run_interaction(self.conn, bad_sql)
# The transaction was rolled back and the connection handed back clean,
# so a following interaction succeeds.
def ok(cursor: Any) -> Optional[list[Any]]:
cursor.execute("SELECT 7::int")
return cursor.fetch_one()
self.assertEqual(run_interaction(self.conn, ok), (7,))
def test_constraint_violation_rolls_back_and_connection_recovers(self) -> None:
"""A constraint violation mid-transaction aborts and rolls back, and the
connection remains usable afterwards."""
def violate(cursor: Any) -> None:
cursor.execute("CREATE TEMP TABLE uniq (x int primary key)")
cursor.execute("INSERT INTO uniq VALUES (1)")
# Duplicate key. The error is reported by the server while the
# statement's result stream is driven, so we drain it (via
# rowcount) to surface it -- as an IntegrityError, the same class
# (with the same 23505 pgcode) it would carry had it surfaced at
# execute time.
cursor.execute("INSERT INTO uniq VALUES (1)")
cursor.rowcount()
with self.assertRaises(postgres.IntegrityError) as ctx:
run_interaction(self.conn, violate)
self.assertEqual(ctx.exception.pgcode, "23505")
# TEMP table lived only in the rolled-back transaction; the connection
# itself is fine.
def ok(cursor: Any) -> Optional[list[Any]]:
cursor.execute("SELECT 1::int")
return cursor.fetch_one()
self.assertEqual(run_interaction(self.conn, ok), (1,))
@unittest.skip_unless(
bool(USE_POSTGRES_FOR_TESTS), "requires a Postgres server (set SYNAPSE_POSTGRES)"
)
class PostgresConnectionDrivenTestCase(unittest.TestCase):
"""Tests for the *connection-driven* transaction API.
Where ``PostgresConnectionTestCase`` runs each interaction in its own
transaction via the ``run_interaction`` test harness, these drive the
connection the way Synapse's own
``synapse.storage.database.new_transaction`` does: open a cursor with
``conn.cursor()``, run statements against it, then ``conn.commit()`` /
``conn.rollback()`` at the *connection* level (while the cursor is still
open). They also cover the implicit ``BEGIN``, the context-manager
protocol, ``close()`` and autocommit.
"""
def setUp(self) -> None:
self.conn = _connect(_build_dsn())
def tearDown(self) -> None:
# Drop the connection before the interpreter shuts down (see the note
# in PostgresConnectionTestCase.tearDown).
del self.conn
# -- small helpers ------------------------------------------------------
def _exec_commit(self, sql: str) -> None:
"""Run a single statement and commit it (its own transaction)."""
cursor = self.conn.cursor()
cursor.execute(sql)
self.conn.commit()
def _scalar(self, sql: str) -> Any:
"""Run a query and return the first column of its single row."""
cursor = self.conn.cursor()
cursor.execute(sql)
row = cursor.fetch_one()
assert row is not None
return row[0]
def _table_exists(self, table: str) -> bool:
return (
self._scalar(
"SELECT count(*)::int FROM information_schema.tables "
f"WHERE table_name = '{table}'"
)
== 1
)
# -- commit / rollback at the connection level --------------------------
def test_commit_persists_changes(self) -> None:
"""Statements run against a cursor are persisted by ``conn.commit()``."""
table = "rust_pg_conn_commit"
try:
cursor = self.conn.cursor()
cursor.execute(f"CREATE TABLE {table} (id int)")
cursor.execute(f"INSERT INTO {table} VALUES (1), (2)")
self.conn.commit()
# A fresh cursor on the same connection (new transaction) sees them.
read = self.conn.cursor()
read.execute(f"SELECT id FROM {table} ORDER BY id")
self.assertEqual(read.fetch_all(), [(1,), (2,)])
self.conn.commit()
finally:
self._exec_commit(f"DROP TABLE IF EXISTS {table}")
def test_rollback_discards_changes(self) -> None:
"""``conn.rollback()`` undoes everything since the implicit ``BEGIN``."""
table = "rust_pg_conn_rollback"
try:
cursor = self.conn.cursor()
cursor.execute(f"CREATE TABLE {table} (id int)")
self.conn.rollback()
# The table creation was rolled back.
self.assertFalse(self._table_exists(table))
self.conn.commit()
finally:
self._exec_commit(f"DROP TABLE IF EXISTS {table}")
def test_commit_without_transaction_is_noop(self) -> None:
"""commit/rollback with no statement run yet are harmless no-ops."""
# No execute() has happened, so no implicit BEGIN was issued.
self.conn.commit()
self.conn.rollback()
# The connection is still usable.
self.assertEqual(self._scalar("SELECT 1::int"), 1)
self.conn.commit()
def test_two_cursors_share_one_transaction(self) -> None:
"""Cursors are cheap views over the connection's single transaction:
work done through one is visible to the other and committed together."""
table = "rust_pg_conn_shared"
try:
first = self.conn.cursor()
first.execute(f"CREATE TABLE {table} (id int)")
# A second cursor opened mid-transaction sees the first's work and
# adds to the same transaction.
second = self.conn.cursor()
second.execute(f"INSERT INTO {table} VALUES (7)")
self.conn.commit()
self.assertEqual(self._scalar(f"SELECT id FROM {table}"), 7)
self.conn.commit()
finally:
self._exec_commit(f"DROP TABLE IF EXISTS {table}")
# -- context manager ----------------------------------------------------
def test_context_manager_commits_on_success(self) -> None:
table = "rust_pg_conn_ctx_commit"
try:
self._exec_commit(f"CREATE TABLE {table} (id int)")
with self.conn as conn:
cursor = conn.cursor()
cursor.execute(f"INSERT INTO {table} VALUES (5)")
# Leaving the block committed.
self.assertEqual(self._scalar(f"SELECT id FROM {table}"), 5)
self.conn.commit()
finally:
self._exec_commit(f"DROP TABLE IF EXISTS {table}")
def test_context_manager_rolls_back_on_exception(self) -> None:
table = "rust_pg_conn_ctx_rollback"
class MarkerError(Exception):
pass
# The body lives in a helper (rather than inline) so the unconditional
# ``raise`` doesn't make mypy treat the rest of the test as unreachable.
def insert_then_fail() -> None:
with self.conn as conn:
cursor = conn.cursor()
cursor.execute(f"INSERT INTO {table} VALUES (6)")
raise MarkerError()
try:
self._exec_commit(f"CREATE TABLE {table} (id int)")
with self.assertRaises(MarkerError):
insert_then_fail()
# The exception caused a rollback, so the row isn't there.
self.assertEqual(self._scalar(f"SELECT count(*)::int FROM {table}"), 0)
self.conn.commit()
finally:
self._exec_commit(f"DROP TABLE IF EXISTS {table}")
# -- close() ------------------------------------------------------------
def test_use_after_close_raises(self) -> None:
self.conn.close()
cursor = self.conn.cursor()
with self.assertRaises(RuntimeError):
cursor.execute("SELECT 1")
def test_close_is_idempotent(self) -> None:
self.conn.close()
# A second close is fine.
self.conn.close()
def test_cursor_close_discards_result_set(self) -> None:
cursor = self.conn.cursor()
cursor.execute("SELECT 1")
cursor.close()
# The result set is gone; a fetch now errors ("no active query").
with self.assertRaises(RuntimeError):
cursor.fetch_one()
self.conn.rollback()
# -- autocommit ---------------------------------------------------------
def test_autocommit_persists_without_explicit_commit(self) -> None:
"""In autocommit mode each statement commits on its own, so a later
``rollback()`` doesn't undo it."""
table = "rust_pg_conn_autocommit"
try:
self.conn.set_autocommit(True)
cursor = self.conn.cursor()
cursor.execute(f"CREATE TABLE {table} (id int)")
# No implicit transaction was opened, so this rolls back nothing.
self.conn.rollback()
self.assertTrue(self._table_exists(table))
finally:
self.conn.set_autocommit(True)
self.conn.cursor().execute(f"DROP TABLE IF EXISTS {table}")
def test_set_autocommit_rejected_mid_transaction(self) -> None:
cursor = self.conn.cursor()
cursor.execute("SELECT 1") # opens an implicit transaction
with self.assertRaises(RuntimeError):
self.conn.set_autocommit(True)
self.conn.rollback()
# -- executescript (multi-statement) ------------------------------------
def test_executescript_runs_all_statements(self) -> None:
"""A `;`-separated script runs every statement (unlike `execute`, which
prepares a single one)."""
table = "rust_pg_script"
try:
cursor = self.conn.cursor()
cursor.executescript(
f"CREATE TABLE {table} (id int); "
f"INSERT INTO {table} VALUES (1); "
f"INSERT INTO {table} VALUES (2), (3);"
)
self.conn.commit()
read = self.conn.cursor()
read.execute(f"SELECT id FROM {table} ORDER BY id")
self.assertEqual(read.fetch_all(), [(1,), (2,), (3,)])
self.conn.commit()
finally:
self._exec_commit(f"DROP TABLE IF EXISTS {table}")
def test_executescript_leaves_transaction_open_for_caller(self) -> None:
"""The script runs in the connection's transaction, left open — so a
following `rollback()` undoes it (it was not autocommitted)."""
table = "rust_pg_script_open"
try:
self.conn.cursor().executescript(f"CREATE TABLE {table} (id int);")
self.conn.rollback()
self.assertFalse(self._table_exists(table))
self.conn.commit()
finally:
self._exec_commit(f"DROP TABLE IF EXISTS {table}")
def test_executescript_error_surfaces_as_database_error(self) -> None:
"""A failing statement mid-script surfaces via the exception hierarchy;
the aborted transaction rolls back cleanly."""
cursor = self.conn.cursor()
with self.assertRaises(postgres.DatabaseError):
cursor.executescript("CREATE TABLE rust_pg_script_bad (id int); NOT SQL;")
self.conn.rollback()
# The CREATE was in the same aborted, rolled-back transaction, so it
# left nothing behind.
self.assertFalse(self._table_exists("rust_pg_script_bad"))
self.conn.commit()
def test_successive_scripts_share_one_transaction(self) -> None:
"""Successive `executescript` calls accumulate in the same open
transaction -- there is no implicit commit between them (unlike
`sqlite3.executescript`) -- so a single rollback discards them all.
This is the atomicity `prepare_database` relies on."""
try:
cursor = self.conn.cursor()
cursor.executescript("CREATE TABLE rust_pg_script_a (id int);")
cursor.executescript("CREATE TABLE rust_pg_script_b (id int);")
# Nothing was committed between the two calls, so one rollback
# undoes both.
self.conn.rollback()
self.assertFalse(self._table_exists("rust_pg_script_a"))
self.assertFalse(self._table_exists("rust_pg_script_b"))
self.conn.commit()
finally:
self._exec_commit("DROP TABLE IF EXISTS rust_pg_script_a")
self._exec_commit("DROP TABLE IF EXISTS rust_pg_script_b")
@unittest.skip_unless(
bool(USE_POSTGRES_FOR_TESTS), "requires a Postgres server (set SYNAPSE_POSTGRES)"
)
class PostgresErrorMappingTestCase(unittest.TestCase):
"""The DBAPI2 exception hierarchy and the SQLSTATE→exception mapping.
Synapse's transaction driver branches on the *type* of the exception a
database call raises (``OperationalError`` → retry, ``IntegrityError`` →
retry upserts) and on its ``pgcode`` (``is_deadlock``). These tests check
the Rust backend raises the right class and carries a ``pgcode``, the way
psycopg2 does.
"""
def setUp(self) -> None:
self.conn = _connect(_build_dsn())
def tearDown(self) -> None:
del self.conn
def _exec_commit(self, sql: str) -> None:
"""Run a single statement and commit it (its own transaction)."""
self.conn.cursor().execute(sql)
self.conn.commit()
# -- the hierarchy exposed on the module --------------------------------
def test_module_exposes_dbapi2_hierarchy(self) -> None:
"""The exception attributes Synapse's engine code and DBAPI2Module
protocol rely on, with the expected subclass links."""
self.assertTrue(issubclass(postgres.DatabaseError, postgres.Error))
self.assertTrue(issubclass(postgres.OperationalError, postgres.DatabaseError))
self.assertTrue(issubclass(postgres.IntegrityError, postgres.DatabaseError))
# -- SQLSTATE → exception class -----------------------------------------
def test_unique_violation_is_integrity_error(self) -> None:
"""A constraint violation raises ``IntegrityError`` with pgcode 23505."""
table = "rust_pg_err_integrity"
try:
self._exec_commit(f"CREATE TABLE {table} (id int PRIMARY KEY)")
self._exec_commit(f"INSERT INTO {table} VALUES (1)")
cursor = self.conn.cursor()
with self.assertRaises(postgres.IntegrityError) as ctx:
cursor.execute(f"INSERT INTO {table} VALUES (1)")
# The INSERT's error is reported while its result stream is
# driven, so drain it (via rowcount) to surface it.
cursor.rowcount()
self.assertEqual(ctx.exception.pgcode, "23505")
self.conn.rollback()
finally:
self._exec_commit(f"DROP TABLE IF EXISTS {table}")
def test_undefined_table_is_plain_database_error(self) -> None:
"""An error we don't single out surfaces as a plain ``DatabaseError``
(not one of the specialised subclasses), still carrying its pgcode."""
cursor = self.conn.cursor()
with self.assertRaises(postgres.DatabaseError) as ctx:
cursor.execute("SELECT * FROM rust_pg_no_such_table")
self.assertNotIsInstance(ctx.exception, postgres.OperationalError)
self.assertNotIsInstance(ctx.exception, postgres.IntegrityError)
self.assertEqual(ctx.exception.pgcode, "42P01") # undefined_table
self.conn.rollback()
@unittest.skip_unless(
bool(USE_POSTGRES_FOR_TESTS) and POSTGRES_HOST in (None, "", "localhost"),
"requires Postgres reachable on libpq's default host",
)
class PostgresDefaultHostTestCase(unittest.TestCase):
"""Covers the libpq default-host fixup in ``connect``.
When the DSN omits ``host=``, ``tokio-postgres`` would default to localhost,
but Synapse wants libpq's default (honouring ``PGHOST`` / the compiled-in
socket dir). This only runs when the test Postgres is actually reachable on
that default host, so it's guarded separately from the main suite.
"""
def test_connect_without_host_uses_libpq_default(self) -> None:
# A DSN with no host=: connecting at all proves the fixup resolved a
# usable default host rather than tokio-postgres' own localhost guess.
parts = [f"dbname={POSTGRES_BASE_DB}"]
if POSTGRES_USER is not None:
parts.append(f"user={POSTGRES_USER}")
if POSTGRES_PORT is not None:
parts.append(f"port={POSTGRES_PORT}")
if POSTGRES_PASSWORD is not None:
parts.append(f"password={POSTGRES_PASSWORD}")
conn = _connect(" ".join(parts))
try:
self.assertEqual(
run_interaction(conn, lambda cursor: _select_one(cursor)), (1,)
)
finally:
del conn
def _select_one(cursor: Any) -> Optional[list[Any]]:
cursor.execute("SELECT 1::int")
return cursor.fetch_one()