mirror of
https://github.com/element-hq/synapse.git
synced 2026-08-16 13:20:39 +00:00
Synapse's schema setup (`prepare_database.py`) runs `;`-separated SQL scripts via the engine's `executescript`. `Cursor.execute` can't serve those: it `prepare`s the query, and Postgres rejects multiple commands in a prepared statement. Add `Cursor.executescript`, which runs the whole script on the simple-query protocol (`batch_execute`), which does allow multiple statements. It takes no parameters and produces no fetchable rows. The script runs inside the connection's *current* transaction (opened lazily like `execute`) and is left open for the caller to commit. It deliberately does NOT reproduce the commit-any-pending-transaction-first behaviour of `sqlite3.executescript` (which psycopg2's engine mirrors with a leading `COMMIT`). That forced commit actually undercuts the atomicity `prepare_database` sets out to get — it opens a transaction so "upgrades are either applied completely, or not at all", but the first script's implicit commit ends it. Running the script within the ongoing transaction instead lets successive scripts accumulate and be committed once, which is both simpler and more correct. The only engine-level piece left to layer on top is the auto-increment placeholder substitution. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
998 lines
39 KiB
Python
998 lines
39 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])
|
|
|
|
# ------------------------------------------------------------------
|
|
# 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])
|
|
|
|
# ------------------------------------------------------------------
|
|
# 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()
|