Files
synapse/rust/src/database/postgres/write.rs
T
Erik JohnstonandClaude Opus 5.5 2f0faa5e48 Trace Rust reads on the native driver
The read paths now report the same spans as the write path:

- `PoolQuerier` (event fetch, stream ID allocation): `db.connection`
  from checkout until it drops, and a `db.query` per statement, tagged
  `db.prepared` when the statement missed the connection's cache.
- The sync store's borrowed connections: `db.connection` from checkout
  until the drop, and for a transaction a `db.txn` (`db.txn_desc` of
  `sync` or `sync_write`, `db.txn_id`, `db.readonly`) with a `commit`
  log, or a `db.rollback` child when a dropped transaction rolls back.
  Each statement is a `db.query`, with `db.begin` when the pending
  BEGIN went with it.
- The state resolution store: `db.connection` → `db.txn`
  (`state_res`, read only) → a `db.query` each for the BEGIN, every
  read and the COMMIT.

As on the write path and in Python, `db.connection` starts once the
connection is in hand, and the wait for it is a `checked out` log on
the caller's span. A failed `db.query` is marked with the `error` tag
and log. `db.query` spans take the held connection or transaction span
as an explicit parent.

The tests collect spans on a current-thread runtime: the registry
releases a span's enter through the thread's default subscriber, so a
span entered on a worker thread would never close under a test's
thread-local subscriber. Production installs the layer globally.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01W4zxygfJC93SsugxskhoJG
2026-10-19 09:55:40 +00:00

2254 lines
81 KiB
Rust

/*
* 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>.
*
*/
//! Write transactions on the native pool, for Rust code that writes many
//! rows to several tables in one transaction.
//!
//! [`run_write_interaction`] is the Rust `runInteraction`: it opens a
//! `REPEATABLE READ` transaction (Python's default isolation), runs the
//! closure, commits, and retries the whole thing on a serialization failure,
//! a deadlock, a cancelled statement or a lost connection, up to five
//! attempts, as
//! `DatabasePool.new_transaction` does.
//!
//! Inside, [`WriteTxn::insert_many`] and [`WriteTxn::upsert_many`] write a
//! batch of rows in one statement however many there are, by passing each
//! column as an array and selecting from `UNNEST` of them. Statements that
//! need a per-row sub-select go through [`WriteTxn::execute_batch`], which
//! pipelines them on the connection.
//!
//! Round trips are what a write transaction mostly waits on, so it sends as
//! much as it can together:
//!
//! - The `BEGIN` goes out with the first statement.
//! - A write whose result nobody reads can be queued with one of the
//! `*_later` methods. Queued writes go out, in order, in the same round
//! trip as the next statement that is not queued, or as the `COMMIT`. A
//! failure surfaces there, reported as the queued statement's own.
//! - Statements not yet prepared on the connection are prepared together.
use std::{
cell::RefCell,
collections::{HashMap, HashSet},
future::Future,
pin::Pin,
sync::{
atomic::{AtomicBool, AtomicU64, AtomicU8, Ordering},
Arc, Mutex,
},
task::Poll,
time::{Duration, Instant},
};
use async_trait::async_trait;
use futures::{
future::{join, maybe_done, poll_fn, BoxFuture},
FutureExt,
};
use log::warn;
use tokio::runtime::Handle;
use tokio_postgres::{types::ToSql, Row};
use tracing::Instrument;
use super::pool::{ConnectionPool, PooledConnection};
use crate::logging::opentracing::layer::is_sampled;
use crate::logging::opentracing::{one_line_sql, record_error};
use crate::storage::events::Querier;
/// Python's `MAX_NUMBER_OF_ATTEMPTS`.
pub const MAX_ATTEMPTS: u32 = 5;
/// Values of [`WriteTxn::failed`].
const NOT_FAILED: u8 = 0;
const FAILED: u8 = 1;
const FAILED_RETRYABLE: u8 = 2;
/// Why a write failed.
#[derive(Debug)]
pub enum WriteError {
/// A constraint was violated (SQLSTATE class `23`). Callers that expect
/// one, such as a conflicting insert, can tell it apart.
Constraint {
/// The SQLSTATE, such as `23505` for a unique violation.
sqlstate: String,
message: String,
},
/// A serialization failure (`40001`) or deadlock (`40P01`): the
/// transaction can be run again.
Conflict(String),
/// The connection was lost: the transaction can be run again on another.
ConnectionLost(String),
/// A statement was cancelled (`57014`), for example by
/// `statement_timeout`. Run again, as Python does.
Cancelled(String),
/// Anything else.
Other(anyhow::Error),
}
/// How [`WriteError::from_pg`] classifies a database error.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum PgErrorKind {
Conflict,
Cancelled,
Constraint,
ConnectionLost,
Other,
}
impl PgErrorKind {
fn of(error: &tokio_postgres::Error) -> Self {
match error.code().map(|code| code.code()) {
Some("40001" | "40P01") => PgErrorKind::Conflict,
Some("57014") => PgErrorKind::Cancelled,
Some(code) if code.starts_with("23") => PgErrorKind::Constraint,
Some(code) if ["08", "57", "58"].iter().any(|c| code.starts_with(c)) => {
PgErrorKind::ConnectionLost
}
None if error.is_closed() => PgErrorKind::ConnectionLost,
_ => PgErrorKind::Other,
}
}
fn is_retryable(self) -> bool {
matches!(
self,
PgErrorKind::Conflict | PgErrorKind::Cancelled | PgErrorKind::ConnectionLost
)
}
}
impl WriteError {
fn from_pg(context: &str, error: tokio_postgres::Error) -> Self {
let message = match error.as_db_error() {
Some(db) => format!("{context}: {} ({})", db.message(), db.code().code()),
None => format!("{context}: {error}"),
};
match PgErrorKind::of(&error) {
PgErrorKind::Conflict => WriteError::Conflict(message),
PgErrorKind::Cancelled => WriteError::Cancelled(message),
PgErrorKind::Constraint => WriteError::Constraint {
sqlstate: error
.code()
.map(|code| code.code().to_owned())
.unwrap_or_default(),
message,
},
PgErrorKind::ConnectionLost => WriteError::ConnectionLost(message),
PgErrorKind::Other => {
WriteError::Other(anyhow::Error::new(error).context(context.to_owned()))
}
}
}
/// Whether running the transaction again may succeed.
fn is_retryable(&self) -> bool {
matches!(
self,
WriteError::Conflict(_) | WriteError::ConnectionLost(_) | WriteError::Cancelled(_)
)
}
}
/// Whether running again the work that failed with `error` may succeed: a
/// cause in its chain is a database error of a kind [`run_write_interaction`]
/// retries, such as a lost connection. For work outside a write transaction,
/// such as a group of reads, which Python runs in a `runInteraction` that
/// retries them.
///
/// A [`WriteError`] in the chain does not count: it comes from a write
/// interaction that has already run its own retries.
pub fn is_retryable_error(error: &anyhow::Error) -> bool {
error.chain().any(|cause| {
if let Some(error) = cause.downcast_ref::<tokio_postgres::Error>() {
PgErrorKind::of(error).is_retryable()
} else if let Some(deadpool::managed::PoolError::Backend(error)) =
cause.downcast_ref::<deadpool::managed::PoolError<tokio_postgres::Error>>()
{
PgErrorKind::of(error).is_retryable()
} else {
false
}
})
}
impl std::fmt::Display for WriteError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
WriteError::Constraint { message, .. } => write!(f, "constraint violation: {message}"),
WriteError::Conflict(message) => write!(f, "transaction conflict: {message}"),
WriteError::ConnectionLost(message) => write!(f, "connection lost: {message}"),
WriteError::Cancelled(message) => write!(f, "statement cancelled: {message}"),
WriteError::Other(error) => write!(f, "{error:#}"),
}
}
}
impl std::error::Error for WriteError {}
impl From<anyhow::Error> for WriteError {
/// A [`WriteError`] that went through `anyhow` on its way out, as one
/// from a [`Querier`] call does, comes back as itself, so that a
/// conflict is still retried. Anything else is [`WriteError::Other`].
fn from(error: anyhow::Error) -> Self {
match error.downcast_ref::<WriteError>() {
Some(WriteError::Other(_)) | None => WriteError::Other(error),
Some(_) => error
.downcast::<WriteError>()
.expect("checked by downcast_ref"),
}
}
}
/// One column of a batch for [`WriteTxn::insert_many`]: its name, the
/// Postgres type of its elements (`text`, `bigint`, `boolean`, ...), and
/// the values, one per row.
pub struct Column<'a> {
name: &'a str,
sql_type: &'a str,
values: &'a (dyn ToSql + Sync),
rows: usize,
}
impl<'a> Column<'a> {
pub fn new<T: ToSql + Sync>(name: &'a str, sql_type: &'a str, values: &'a Vec<T>) -> Self
where
Vec<T>: ToSql + Sync,
{
Column {
name,
sql_type,
values,
rows: values.len(),
}
}
}
/// A parameter of a queued statement, which owns its value.
pub type OwnedParam = Box<dyn ToSql + Sync + Send>;
/// One column of a batch for [`WriteTxn::insert_many_later`] and
/// [`WriteTxn::upsert_many_later`]: like [`Column`], but owning its values,
/// as the statement is sent after the call returns.
pub struct OwnedColumn {
name: &'static str,
sql_type: &'static str,
values: OwnedParam,
rows: usize,
}
impl OwnedColumn {
pub fn new<T>(name: &'static str, sql_type: &'static str, values: Vec<T>) -> Self
where
Vec<T>: ToSql + Sync + Send + 'static,
{
OwnedColumn {
name,
sql_type,
rows: values.len(),
values: Box::new(values),
}
}
}
impl OwnedColumn {
/// A column of copies of `values`, for values borrowed from data that
/// does not outlive the call, such as `&str`s of an event.
pub fn copy<T: ToOwnedSql>(name: &'static str, sql_type: &'static str, values: &[T]) -> Self
where
Vec<T::Owned>: ToSql + Sync + Send + 'static,
{
OwnedColumn::new(name, sql_type, values.iter().map(T::to_owned_sql).collect())
}
}
/// A value that can be copied into one a queued statement can own.
pub trait ToOwnedSql {
type Owned;
fn to_owned_sql(&self) -> Self::Owned;
}
impl ToOwnedSql for str {
type Owned = String;
fn to_owned_sql(&self) -> String {
self.to_owned()
}
}
impl<T: ToOwnedSql + ?Sized> ToOwnedSql for &T {
type Owned = T::Owned;
fn to_owned_sql(&self) -> T::Owned {
(**self).to_owned_sql()
}
}
impl<T: ToOwnedSql> ToOwnedSql for Vec<T> {
type Owned = Vec<T::Owned>;
fn to_owned_sql(&self) -> Vec<T::Owned> {
self.iter().map(T::to_owned_sql).collect()
}
}
impl<T: ToOwnedSql> ToOwnedSql for [T] {
type Owned = Vec<T::Owned>;
fn to_owned_sql(&self) -> Vec<T::Owned> {
self.iter().map(T::to_owned_sql).collect()
}
}
impl ToOwnedSql for Option<&str> {
type Owned = Option<String>;
fn to_owned_sql(&self) -> Option<String> {
self.map(str::to_owned)
}
}
impl ToOwnedSql for String {
type Owned = String;
fn to_owned_sql(&self) -> String {
self.clone()
}
}
impl ToOwnedSql for Option<String> {
type Owned = Option<String>;
fn to_owned_sql(&self) -> Option<String> {
self.clone()
}
}
macro_rules! copy_to_owned_sql {
($($t:ty),*) => {
$(impl ToOwnedSql for $t {
type Owned = $t;
fn to_owned_sql(&self) -> $t {
*self
}
})*
};
}
copy_to_owned_sql!(i32, i64, bool, Option<i32>, Option<i64>, Option<bool>);
/// The parameters of a queued statement, copied from borrowed values: for
/// [`WriteTxn::execute_later`].
#[macro_export]
macro_rules! owned_params {
($($param:expr),* $(,)?) => {
vec![$(Box::new(
$crate::database::postgres::write::ToOwnedSql::to_owned_sql(&$param),
) as $crate::database::postgres::write::OwnedParam),*]
};
}
/// How to turn the error of a queued statement into the one its caller
/// wants, e.g. a constraint violation into a domain error.
pub type MapError = Box<dyn FnOnce(WriteError) -> WriteError + Send + Sync>;
/// A statement queued by one of the `*_later` methods.
struct Queued {
/// Shared by the rows of an [`WriteTxn::execute_batch_later`].
sql: Arc<str>,
params: Vec<OwnedParam>,
map_error: Option<MapError>,
}
/// An open transaction. See the module docs. Rolled back if dropped before
/// [`WriteTxn::commit`].
///
/// Its statement methods must not run concurrently: a statement could then
/// be sent before writes queued ahead of it.
pub struct WriteTxn {
conn: Option<PooledConnection>,
in_transaction: bool,
/// The `BEGIN` has not been sent yet. The first statement sends it in
/// front of its own first request, so that they share a round trip.
begin_pending: AtomicBool,
/// The `BEGIN` failed, so the statement sent behind it may have run
/// outside a transaction: the connection is discarded, not reused.
begin_failed: AtomicBool,
/// Writes whose results nobody reads, not yet sent. They go out, in
/// order, ahead of the next statement that is not queued, or of the
/// `COMMIT`, in the same round trip. Never locked across an await.
queued: Mutex<Vec<Queued>>,
/// A statement failed, so Postgres has aborted the transaction. A
/// `COMMIT` would then roll back without an error, so the closure must
/// not be able to report success after catching the error. Holds one of
/// the `FAILED_*` values, for the first failure.
failed: AtomicU8,
}
impl WriteTxn {
/// Start a transaction on `conn`. Nothing is sent until the first
/// statement, which sends the `BEGIN` with it.
fn begin(conn: PooledConnection) -> WriteTxn {
WriteTxn {
conn: Some(conn),
// Set before anything is sent: if we are dropped after the BEGIN
// goes out but before it returns, the transaction is open and
// must be rolled back.
in_transaction: true,
begin_pending: AtomicBool::new(true),
begin_failed: AtomicBool::new(false),
queued: Mutex::new(Vec::new()),
failed: AtomicU8::new(NOT_FAILED),
}
}
/// The `BEGIN`, if no statement has sent it yet. The caller must send it
/// in front of its statement.
fn take_begin(&self) -> Option<BoxFuture<'_, Result<(), tokio_postgres::Error>>> {
self.begin_pending.swap(false, Ordering::Relaxed).then(|| {
self.conn()
.batch_execute("BEGIN ISOLATION LEVEL REPEATABLE READ")
.boxed()
})
}
fn conn(&self) -> &PooledConnection {
self.conn.as_ref().expect("held until dropped")
}
fn take_queued(&self) -> Vec<Queued> {
std::mem::take(&mut *self.queued.lock().expect("not poisoned"))
}
/// The error for a statement that could not be sent, such as one whose
/// parameters do not encode. The transaction must not be committed after
/// it.
fn client_failed(&self, context: &str, error: anyhow::Error) -> WriteError {
let _ =
self.failed
.compare_exchange(NOT_FAILED, FAILED, Ordering::Relaxed, Ordering::Relaxed);
WriteError::Other(error.context(context.to_owned()))
}
/// The error for a failed statement, noting that the transaction is now
/// aborted.
fn statement_failed(&self, context: &str, error: tokio_postgres::Error) -> WriteError {
let error = WriteError::from_pg(context, error);
let failed = if error.is_retryable() {
FAILED_RETRYABLE
} else {
FAILED
};
// Keep the first: later statements fail only because it aborted the
// transaction.
let _ =
self.failed
.compare_exchange(NOT_FAILED, failed, Ordering::Relaxed, Ordering::Relaxed);
error
}
/// Send the queued writes, then the request `last` builds for the
/// statement `sql` (prepared, if there is one), all in one round trip,
/// with the `BEGIN` in front if it is still to be sent. Statements not
/// yet prepared on this connection are prepared first, together, in one
/// more round trip.
///
/// Requests go on the wire in this order, as tokio-postgres sends each
/// when its future is first polled and [`join_in_order`] polls them in
/// order. A failure is reported as the first one on the wire: the
/// requests after it fail only because it aborted the transaction.
///
/// Each call is one `db.query` span: the unit the pipelining saves is a
/// round trip, not a statement. Its `db.statement` is the statement
/// `sqls` names (or `label`, for the `COMMIT`), and `db.queued`,
/// `db.queued_statements`, `db.prepared` and `db.begin` say what else
/// went with it.
async fn send<'s, T, F>(
&'s self,
sqls: &[&str],
label: Option<&'static str>,
last: impl FnOnce(&'s PooledConnection, Vec<tokio_postgres::Statement>) -> F,
context: impl FnOnce() -> String,
) -> Result<T, WriteError>
where
F: Future<Output = Result<T, tokio_postgres::Error>> + 's,
{
let span = tracing::info_span!(
"db.query",
db.type = "sql",
db.statement = tracing::field::Empty,
db.queued = tracing::field::Empty,
db.queued_statements = tracing::field::Empty,
db.prepared = tracing::field::Empty,
db.begin = tracing::field::Empty,
error = tracing::field::Empty,
);
let sampled = is_sampled(&span);
if sampled {
let statement = match label {
Some(label) => label.to_owned(),
None => sqls
.iter()
.map(|sql| one_line_sql(sql))
.collect::<Vec<_>>()
.join("; "),
};
if !statement.is_empty() {
span.record("db.statement", statement.as_str());
}
}
let result = self
.send_in_span(sqls, last, context, &span, sampled)
.instrument(span.clone())
.await;
if let Err(error) = &result {
record_error(&span, error);
}
result
}
async fn send_in_span<'s, T, F>(
&'s self,
sqls: &[&str],
last: impl FnOnce(&'s PooledConnection, Vec<tokio_postgres::Statement>) -> F,
context: impl FnOnce() -> String,
span: &tracing::Span,
sampled: bool,
) -> Result<T, WriteError>
where
F: Future<Output = Result<T, tokio_postgres::Error>> + 's,
{
let conn = self.conn();
let queued = self.take_queued();
let mut begin = self.take_begin();
if sampled {
span.record("db.queued", queued.len() as u64);
span.record("db.begin", begin.is_some());
if !queued.is_empty() {
// Each distinct statement once, in order: a batch queues the
// same statement once per row.
let mut seen = HashSet::new();
let statements: Vec<String> = queued
.iter()
.filter(|q| seen.insert(&*q.sql))
.map(|q| one_line_sql(&q.sql))
.collect();
span.record("db.queued_statements", statements.join("; ").as_str());
}
}
let sqls: Vec<&str> = queued
.iter()
.map(|q| &*q.sql)
.chain(sqls.iter().copied())
.collect();
// Each distinct statement once: a batch queues one row per statement.
let mut by_sql: HashMap<&str, Option<tokio_postgres::Statement>> = HashMap::new();
for sql in &sqls {
by_sql.entry(sql).or_insert_with(|| conn.cached(sql));
}
let mut missing: Vec<&str> = by_sql
.iter()
.filter(|(_, statement)| statement.is_none())
.map(|(sql, _)| *sql)
.collect();
missing.sort_unstable();
if sampled {
span.record("db.prepared", missing.len() as u64);
}
if !missing.is_empty() {
// A round trip of its own, so a span of its own.
let prepare_span = tracing::info_span!(
"db.prepare",
db.prepared = missing.len() as u64,
db.begin = begin.is_some(),
);
let prepares = join_in_order(missing.iter().map(|sql| conn.prepare_cached(sql, false)));
let prepared = match begin.take() {
None => prepares.instrument(prepare_span).await,
Some(begin) => {
let (begun, prepared) = join(begin, prepares).instrument(prepare_span).await;
if let Err(e) = begun {
self.begin_failed.store(true, Ordering::Relaxed);
return Err(self.statement_failed("opening a transaction", e));
}
prepared
}
};
for (sql, result) in missing.iter().zip(prepared) {
let statement = result.map_err(|e| {
self.statement_failed(&format!("preparing {}", describe(sql)), e)
})?;
by_sql.insert(sql, Some(statement));
}
}
let mut statements = sqls
.iter()
.map(|sql| by_sql[sql].clone().expect("cached or prepared above"));
let queued_statements: Vec<tokio_postgres::Statement> =
statements.by_ref().take(queued.len()).collect();
let last_statements: Vec<tokio_postgres::Statement> = statements.collect();
// tokio-postgres encodes a request's parameters when it is first
// polled, and if that fails it sends nothing, while the requests
// after it still go out, `COMMIT` included. So check that every
// queued write's parameters encode before sending any of them.
for (q, statement) in queued.iter().zip(&queued_statements) {
if let Err(error) = check_params(statement, &q.params) {
return Err(self.client_failed(&describe(&q.sql), error));
}
}
let writes = join_in_order(queued.iter().zip(&queued_statements).map(|(q, statement)| {
conn.execute_raw(
statement,
q.params.iter().map(|p| p.as_ref() as &(dyn ToSql + Sync)),
)
}));
let requests = join(writes, last(conn, last_statements));
let (written, result) = match begin {
None => requests.await,
Some(begin) => {
let (begun, results) = join(begin, requests).await;
if let Err(e) = begun {
self.begin_failed.store(true, Ordering::Relaxed);
return Err(self.statement_failed("opening a transaction", e));
}
results
}
};
for (q, outcome) in queued.into_iter().zip(written) {
if let Err(e) = outcome {
let error = self.statement_failed(&describe(&q.sql), e);
return Err(match q.map_error {
Some(map_error) => map_error(error),
None => error,
});
}
}
result.map_err(|e| self.statement_failed(&context(), e))
}
/// Run a statement that returns rows, after the queued writes.
pub async fn query(
&self,
sql: &str,
params: &[&(dyn ToSql + Sync)],
) -> Result<Vec<Row>, WriteError> {
self.send(
&[sql],
None,
|conn, statements| async move { conn.query(&statements[0], params).await },
|| describe(sql),
)
.await
}
/// Run several statements that return rows, after the queued writes, all
/// in one round trip: their rows, in order. For reads that need nothing
/// from each other.
pub async fn query_many(
&self,
queries: &[(&str, &[&(dyn ToSql + Sync)])],
) -> Result<Vec<Vec<Row>>, WriteError> {
let sqls: Vec<&str> = queries.iter().map(|(sql, _)| *sql).collect();
self.send(
&sqls,
None,
|conn, statements| async move {
let results = join_in_order(
statements
.iter()
.zip(queries)
.map(|(statement, (_, params))| conn.query(statement, params)),
)
.await;
results.into_iter().collect()
},
|| {
sqls.iter()
.map(|sql| describe(sql))
.collect::<Vec<_>>()
.join("; ")
},
)
.await
}
/// Run a statement, after the queued writes, returning the number of rows
/// it changed.
pub async fn execute(
&self,
sql: &str,
params: &[&(dyn ToSql + Sync)],
) -> Result<u64, WriteError> {
self.send(
&[sql],
None,
|conn, statements| async move { conn.execute(&statements[0], params).await },
|| describe(sql),
)
.await
}
/// Run one statement once per set of parameters, pipelined on the
/// connection after the queued writes, returning the total number of
/// rows changed. For the statements that need a sub-select per row; the
/// rest can use [`WriteTxn::insert_many`].
pub async fn execute_batch(
&self,
sql: &str,
rows: &[Vec<&(dyn ToSql + Sync)>],
) -> Result<u64, WriteError> {
if rows.is_empty() {
return Ok(0);
}
self.send(
&[sql],
None,
|conn, statements| async move {
let statement = &statements[0];
let counts =
join_in_order(rows.iter().map(|params| conn.execute(statement, params))).await;
counts.into_iter().sum::<Result<u64, _>>()
},
|| describe(sql),
)
.await
}
/// Send the queued writes now, and wait for them.
pub async fn flush(&self) -> Result<(), WriteError> {
if self.queued.lock().expect("not poisoned").is_empty() {
return Ok(());
}
self.send(&[], None, |_, _| async { Ok(()) }, String::new)
.await
}
/// Queue a write whose result nobody reads. It goes out ahead of the
/// next statement that is not queued, or of the `COMMIT`, and a failure
/// surfaces there.
///
/// Queued statements are all prepared before the first of them runs, so
/// one must not depend on a schema change an earlier queued one makes.
pub fn execute_later(&self, sql: impl Into<String>, params: Vec<OwnedParam>) {
self.queue(Arc::from(sql.into()), params, None);
}
/// As [`WriteTxn::execute_later`], with `map_error` applied to the
/// statement's error.
pub fn execute_later_mapping_error(
&self,
sql: impl Into<String>,
params: Vec<OwnedParam>,
map_error: MapError,
) {
self.queue(Arc::from(sql.into()), params, Some(map_error));
}
/// As [`WriteTxn::execute_batch`], queued as [`WriteTxn::execute_later`]:
/// the statement once per row of parameters.
pub fn execute_batch_later(&self, sql: &str, rows: Vec<Vec<OwnedParam>>) {
let sql: Arc<str> = Arc::from(sql);
for params in rows {
self.queue(Arc::clone(&sql), params, None);
}
}
fn queue(&self, sql: Arc<str>, params: Vec<OwnedParam>, map_error: Option<MapError>) {
self.queued.lock().expect("not poisoned").push(Queued {
sql,
params,
map_error,
});
}
/// As [`WriteTxn::insert_many`], queued as [`WriteTxn::execute_later`].
pub fn insert_many_later(
&self,
table: &str,
columns: Vec<OwnedColumn>,
on_conflict: Option<&str>,
) -> Result<(), WriteError> {
let shapes: Vec<(&str, &str, usize)> = columns
.iter()
.map(|c| (c.name, c.sql_type, c.rows))
.collect();
if check_batch(table, &shapes)?.is_none() {
return Ok(());
}
let mut sql = unnest_insert_sql(table, &shapes);
if let Some(on_conflict) = on_conflict {
sql.push(' ');
sql.push_str(on_conflict);
}
self.execute_later(sql, columns.into_iter().map(|c| c.values).collect());
Ok(())
}
/// As [`WriteTxn::upsert_many`], queued as [`WriteTxn::execute_later`].
pub fn upsert_many_later(
&self,
table: &str,
keys: Vec<OwnedColumn>,
values: Vec<OwnedColumn>,
) -> Result<(), WriteError> {
let shapes: Vec<(&str, &str, usize)> = keys
.iter()
.chain(&values)
.map(|c| (c.name, c.sql_type, c.rows))
.collect();
if check_batch(table, &shapes)?.is_none() {
return Ok(());
}
let key_names: Vec<&str> = keys.iter().map(|c| c.name).collect();
let value_names: Vec<&str> = values.iter().map(|c| c.name).collect();
let sql = unnest_insert_sql(table, &shapes) + &on_conflict_update(&key_names, &value_names);
self.execute_later(
sql,
keys.into_iter().chain(values).map(|c| c.values).collect(),
);
Ok(())
}
/// Insert a batch of rows in one statement, returning the number
/// inserted. Does nothing for an empty batch. With `on_conflict`, that
/// clause is added, for example `ON CONFLICT DO NOTHING`.
pub async fn insert_many(
&self,
table: &str,
columns: &[Column<'_>],
on_conflict: Option<&str>,
) -> Result<u64, WriteError> {
let shapes: Vec<(&str, &str, usize)> = columns
.iter()
.map(|c| (c.name, c.sql_type, c.rows))
.collect();
if check_batch(table, &shapes)?.is_none() {
return Ok(0);
}
let mut sql = unnest_insert_sql(table, &shapes);
if let Some(on_conflict) = on_conflict {
sql.push(' ');
sql.push_str(on_conflict);
}
let params: Vec<&(dyn ToSql + Sync)> = columns.iter().map(|c| c.values).collect();
self.execute(&sql, &params).await
}
/// Insert a batch of rows, or update the `values` columns of the rows
/// whose `keys` columns match, in one statement. With no `values`
/// columns, existing rows are left alone. With `values`, two rows of the
/// batch with the same key fail the statement, as in Python's
/// `simple_upsert_many_txn`. Without, the first of them is kept.
pub async fn upsert_many(
&self,
table: &str,
keys: &[Column<'_>],
values: &[Column<'_>],
) -> Result<u64, WriteError> {
let columns: Vec<&Column<'_>> = keys.iter().chain(values).collect();
let shapes: Vec<(&str, &str, usize)> = columns
.iter()
.map(|c| (c.name, c.sql_type, c.rows))
.collect();
if check_batch(table, &shapes)?.is_none() {
return Ok(0);
}
let key_names: Vec<&str> = keys.iter().map(|c| c.name).collect();
let value_names: Vec<&str> = values.iter().map(|c| c.name).collect();
let sql = unnest_insert_sql(table, &shapes) + &on_conflict_update(&key_names, &value_names);
let params: Vec<&(dyn ToSql + Sync)> = columns.iter().map(|c| c.values).collect();
self.execute(&sql, &params).await
}
/// Send the queued writes and commit, in one round trip.
async fn commit(mut self) -> Result<(), WriteError> {
match self.failed.load(Ordering::Relaxed) {
NOT_FAILED => {}
// The closure caught an error that a retry may get past, so
// retry rather than fail for good.
FAILED_RETRYABLE => {
self.rollback().await;
return Err(WriteError::Conflict(
"a statement failed with a retryable error, so the transaction was rolled back"
.to_owned(),
));
}
_ => {
self.rollback().await;
return Err(WriteError::Other(anyhow::anyhow!(
"a statement failed, so the transaction was rolled back"
)));
}
}
if self.begin_pending.load(Ordering::Relaxed)
&& self.queued.lock().expect("not poisoned").is_empty()
{
// No statement ran, so there is no transaction to commit.
self.in_transaction = false;
return Ok(());
}
tracing::info!(message = "commit");
self.send(
&[],
Some("COMMIT"),
|conn, _| conn.batch_execute("COMMIT"),
|| "committing".to_owned(),
)
.await?;
self.in_transaction = false;
Ok(())
}
/// Roll back now, rather than on a background task when dropped, so a
/// retry does not race the rollback for the rows. Queued writes are
/// dropped unsent.
async fn rollback(mut self) {
self.take_queued();
if self.begin_pending.load(Ordering::Relaxed) {
// No statement ran, so there is no transaction to roll back.
self.in_transaction = false;
return;
}
if self.begin_failed.load(Ordering::Relaxed) {
// Dropping discards the connection.
return;
}
let rolled_back = self
.conn()
.batch_execute("ROLLBACK")
.instrument(tracing::info_span!("db.rollback"))
.await;
if let Err(e) = rolled_back {
warn!("Failed to roll back a write transaction: {e}");
// Leave `in_transaction` set: dropping discards the connection
// rather than returning it to the pool.
if let Some(conn) = self.conn.take() {
drop(PooledConnection::take(conn));
}
return;
}
self.in_transaction = false;
}
}
/// Check that `params` encode as `statement`'s parameter types, as
/// tokio-postgres would when sending it.
fn check_params(
statement: &tokio_postgres::Statement,
params: &[OwnedParam],
) -> anyhow::Result<()> {
let types = statement.params();
if types.len() != params.len() {
anyhow::bail!(
"statement takes {} parameters but {} were given",
types.len(),
params.len()
);
}
let mut buf = bytes::BytesMut::new();
for (i, (param, ty)) in params.iter().zip(types).enumerate() {
buf.clear();
param
.to_sql_checked(ty, &mut buf)
.map_err(|e| anyhow::anyhow!("error serializing parameter {i}: {e}"))?;
}
Ok(())
}
/// Await `futures` together, polling every one still pending, in order, on
/// each wake-up. tokio-postgres sends a request when its future is first
/// polled, so the requests go out in the order given. (`join_all` and
/// `try_join_all` do not promise an order for long lists.)
pub(crate) fn join_in_order<F: Future>(
futures: impl IntoIterator<Item = F>,
) -> impl Future<Output = Vec<F::Output>> {
let mut futures: Vec<_> = futures
.into_iter()
.map(|future| maybe_done(Box::pin(future)))
.collect();
poll_fn(move |cx| {
let mut done = true;
for future in futures.iter_mut() {
if Pin::new(future).poll(cx).is_pending() {
done = false;
}
}
if !done {
return Poll::Pending;
}
Poll::Ready(
futures
.iter_mut()
.map(|future| {
Pin::new(future)
.take_output()
.expect("polled to completion above")
})
.collect(),
)
})
}
/// For the read helpers shared with the read-only paths, such as the chain
/// cover reads in `persist::reader`.
#[async_trait]
impl Querier for WriteTxn {
async fn query(&self, sql: &str, params: &[&(dyn ToSql + Sync)]) -> anyhow::Result<Vec<Row>> {
WriteTxn::query(self, sql, params)
.await
.map_err(anyhow::Error::new)
}
}
impl Drop for WriteTxn {
fn drop(&mut self) {
let Some(conn) = self.conn.take() else { return };
if self.begin_failed.load(Ordering::Relaxed) {
// The session state is unknown: discard the connection.
drop(PooledConnection::take(conn));
return;
}
if !self.in_transaction || self.begin_pending.load(Ordering::Relaxed) {
return;
}
// Roll back before the connection goes back to the pool, so the next
// user doesn't find it inside our transaction. Without a runtime to
// do that on, the connection is dropped rather than returned.
match Handle::try_current() {
Ok(handle) => {
handle.spawn(async move {
if let Err(e) = conn.batch_execute("ROLLBACK").await {
warn!("Failed to roll back a write transaction: {e}");
}
drop(conn);
});
}
Err(_) => {
drop(PooledConnection::take(conn));
}
}
}
}
/// Run `f` in a write transaction and commit it, running it again from the
/// start on a serialization failure, a deadlock or a lost connection, up to
/// five attempts in all. `name` labels the warnings. See the module docs.
///
/// `f` must leave no trace outside the database before it returns, or reset
/// what it left at its start, since a retry runs it again.
///
/// `f` takes a second argument it ignores: its type, `&'t &'env ()`, tells
/// the compiler that the borrows `f` captures (of lifetime `'env`) outlive
/// the transaction (`'t`), so the future may use them. Call it as
/// `run_write_interaction(pool, name, |txn, _| async move { .. }.boxed())`.
pub async fn run_write_interaction<'env, T, F>(
pool: &ConnectionPool,
name: &str,
f: F,
) -> Result<T, WriteError>
where
F: for<'t> FnMut(&'t WriteTxn, &'t &'env ()) -> BoxFuture<'t, Result<T, WriteError>>,
{
run_interaction(pool, name, true, f).await
}
/// As [`run_write_interaction`], but rolls the transaction back even when
/// `f` succeeds. For a dry run whose writes must not be seen.
pub async fn run_rolled_back_interaction<'env, T, F>(
pool: &ConnectionPool,
name: &str,
f: F,
) -> Result<T, WriteError>
where
F: for<'t> FnMut(&'t WriteTxn, &'t &'env ()) -> BoxFuture<'t, Result<T, WriteError>>,
{
run_interaction(pool, name, false, f).await
}
async fn run_interaction<'env, T, F>(
pool: &ConnectionPool,
name: &str,
commit: bool,
mut f: F,
) -> Result<T, WriteError>
where
F: for<'t> FnMut(&'t WriteTxn, &'t &'env ()) -> BoxFuture<'t, Result<T, WriteError>>,
{
// As `new_transaction`, from when each attempt has its connection: the
// wait for one is not the transaction's.
let mut elapsed = Duration::ZERO;
let mut attempt = 1;
// One ID for every attempt, as Python names the transaction once.
let txn_id = next_txn_id();
// Python's `db.{desc}` span: the whole interaction, every attempt and
// the waits for a connection included.
let span = tracing::info_span!(
"db.interaction",
otel.name = %format_args!("db.{name}"),
error = tracing::field::Empty,
);
let result = async {
loop {
let result = run_once(pool, name, txn_id, attempt, commit, &mut f, &mut elapsed).await;
match result {
Err(error) if error.is_retryable() && attempt < MAX_ATTEMPTS => {
warn!("[TXN RETRY] {{{name}}} {error} {attempt}/{MAX_ATTEMPTS}");
tracing::info!(message = "retry", error = %error, attempt);
attempt += 1;
}
result => break result,
}
}
}
.instrument(span.clone())
.await;
if let Err(error) = &result {
record_error(&span, error);
}
record_txn_timing(name, elapsed);
result
}
/// Numbers transactions for their `db.txn_id` tag, as Python numbers its own.
static NEXT_TXN_ID: AtomicU64 = AtomicU64::new(0);
/// A number for a transaction's `db.txn_id` tag, `{desc}-{number:x}`.
pub(crate) fn next_txn_id() -> u64 {
NEXT_TXN_ID.fetch_add(1, Ordering::Relaxed)
}
/// What each write interaction run in a [`with_txn_timings`] scope took,
/// as `(name, seconds)`, in the order they finished.
pub type TxnTimings = Vec<(String, f64)>;
tokio::task_local! {
static TXN_TIMINGS: RefCell<TxnTimings>;
}
/// Run `future`, and return with its output what each write interaction
/// it ran took, whether it succeeded or not, retries included and the wait
/// for a connection left out, as `DatabasePool.new_transaction` times
/// them. A caller whose future fails must still report them. Python records them where it
/// records its own (`sql_txn_count`, `sql_txn_duration` and the log
/// context's transaction counts), which Rust cannot reach without the GIL.
/// Interactions run on other tasks are not seen.
pub async fn with_txn_timings<F: Future>(future: F) -> (F::Output, TxnTimings) {
TXN_TIMINGS
.scope(RefCell::new(Vec::new()), async {
let output = future.await;
let timings = TXN_TIMINGS.with(|timings| timings.take());
(output, timings)
})
.await
}
/// Record that an interaction run outside [`run_write_interaction`] took
/// `elapsed`, if a [`with_txn_timings`] scope is listening.
pub fn record_txn_timing(name: &str, elapsed: Duration) {
let _ = TXN_TIMINGS.try_with(|timings| {
timings
.borrow_mut()
.push((name.to_owned(), elapsed.as_secs_f64()))
});
}
/// One attempt, adding its time from when it has a connection to
/// `elapsed`.
async fn run_once<'env, T, F>(
pool: &ConnectionPool,
name: &str,
txn_id: u64,
attempt_number: u32,
commit: bool,
f: &mut F,
elapsed: &mut Duration,
) -> Result<T, WriteError>
where
F: for<'t> FnMut(&'t WriteTxn, &'t &'env ()) -> BoxFuture<'t, Result<T, WriteError>>,
{
let waiting = Instant::now();
let conn = pool.get().await.map_err(|e| match e {
deadpool::managed::PoolError::Backend(e) => {
WriteError::from_pg("checking out a database connection", e)
}
e => WriteError::Other(anyhow::anyhow!("checking out a database connection: {e}")),
})?;
// On the interaction's span: Python records the wait for a connection
// outside `db.connection` too.
tracing::info!(
message = "checked out",
wait_ms = waiting.elapsed().as_secs_f64() * 1000.0
);
// Python's `db.connection`: from having the connection to giving it
// back.
let start = Instant::now();
let result = attempt(conn, name, txn_id, attempt_number, commit, f)
.instrument(tracing::info_span!("db.connection"))
.await;
*elapsed += start.elapsed();
result
}
async fn attempt<'env, T, F>(
conn: PooledConnection,
name: &str,
txn_id: u64,
attempt_number: u32,
commit: bool,
f: &mut F,
) -> Result<T, WriteError>
where
F: for<'t> FnMut(&'t WriteTxn, &'t &'env ()) -> BoxFuture<'t, Result<T, WriteError>>,
{
let span = tracing::info_span!(
"db.txn",
db.txn_desc = name,
db.txn_id = %format_args!("{name}-{txn_id:x}"),
db.attempt = attempt_number,
error = tracing::field::Empty,
);
let result = async {
let txn = WriteTxn::begin(conn);
match f(&txn, &&()).await {
Ok(value) if commit => {
txn.commit().await?;
Ok(value)
}
Ok(value) => {
// A dry run still checks its queued writes.
let flushed = txn.flush().await;
txn.rollback().await;
flushed.map(|()| value)
}
Err(error) => {
txn.rollback().await;
Err(error)
}
}
}
.instrument(span.clone())
.await;
if let Err(error) = &result {
record_error(&span, error);
}
result
}
/// The batch's row count, or `None` for an empty batch. All columns must
/// have one value per row.
/// The batch's row count, given each column's `(name, type, rows)`, or
/// `None` for an empty batch. All columns must have the same number of rows.
fn check_batch(table: &str, columns: &[(&str, &str, usize)]) -> Result<Option<usize>, WriteError> {
let Some(&(first_name, _, first_rows)) = columns.first() else {
return Err(anyhow::anyhow!("a batch for {table} with no columns").into());
};
if let Some(&(name, _, rows)) = columns.iter().find(|c| c.2 != first_rows) {
return Err(anyhow::anyhow!(
"a batch for {table} with {first_rows} values of {first_name} and {rows} of {name}"
)
.into());
}
Ok((first_rows > 0).then_some(first_rows))
}
/// `INSERT INTO table (a, b) SELECT * FROM UNNEST($1::text[], $2::bigint[])`,
/// given each column's `(name, type, rows)`.
fn unnest_insert_sql(table: &str, columns: &[(&str, &str, usize)]) -> String {
let names: Vec<&str> = columns.iter().map(|c| c.0).collect();
let arrays: Vec<String> = columns
.iter()
.enumerate()
.map(|(i, c)| format!("${}::{}[]", i + 1, c.1))
.collect();
format!(
"INSERT INTO {table} ({}) SELECT * FROM UNNEST({})",
names.join(", "),
arrays.join(", ")
)
}
/// The `ON CONFLICT` clause of an upsert: do nothing without `values`,
/// otherwise update them from the new row.
fn on_conflict_update(keys: &[&str], values: &[&str]) -> String {
if values.is_empty() {
return format!(" ON CONFLICT ({}) DO NOTHING", keys.join(", "));
}
let updates: Vec<String> = values
.iter()
.map(|name| format!("{name} = EXCLUDED.{name}"))
.collect();
format!(
" ON CONFLICT ({}) DO UPDATE SET {}",
keys.join(", "),
updates.join(", ")
)
}
/// The start of a statement, for error messages.
fn describe(sql: &str) -> String {
sql.split_whitespace().take(6).collect::<Vec<_>>().join(" ")
}
#[cfg(test)]
mod tests {
//! Against a real server: these run when `SYNAPSE_TEST_POSTGRES_DSN` is
//! set (e.g. to `host=postgres user=postgres password=postgres`), and
//! otherwise do nothing.
use std::sync::{
atomic::{AtomicU32, Ordering},
Arc,
};
use anyhow::Context;
use futures::FutureExt;
use tokio::runtime::Runtime;
use super::*;
use crate::database::postgres::pool::{create_pool, PoolConfig};
use crate::logging::opentracing::thrift::{FinishedSpan, Tag};
fn setup() -> Option<(Runtime, ConnectionPool, String)> {
let Ok(dsn) = std::env::var("SYNAPSE_TEST_POSTGRES_DSN") else {
eprintln!("skipping: set SYNAPSE_TEST_POSTGRES_DSN to run");
return None;
};
let rt = tokio::runtime::Builder::new_multi_thread()
.worker_threads(2)
.enable_all()
.build()
.unwrap();
let pool = create_pool(
&dsn,
rt.handle().clone(),
PoolConfig {
max_size: 3,
..PoolConfig::default()
},
)
.unwrap();
// A table of its own per test, as tests run in parallel.
let table = format!(
"write_txn_test_{}",
ulid::Ulid::generate().to_string().to_lowercase()
);
rt.block_on(async {
pool.get()
.await
.unwrap()
.batch_execute(&format!(
"CREATE TABLE {table} (
id TEXT PRIMARY KEY,
n BIGINT NOT NULL,
flag BOOLEAN,
note TEXT
)"
))
.await
.unwrap();
});
Some((rt, pool, table))
}
fn teardown(rt: &Runtime, pool: &ConnectionPool, table: &str) {
rt.block_on(async {
pool.get()
.await
.unwrap()
.batch_execute(&format!("DROP TABLE {table}"))
.await
.unwrap();
});
}
async fn rows(
pool: &ConnectionPool,
table: &str,
) -> Vec<(String, i64, Option<bool>, Option<String>)> {
pool.get()
.await
.unwrap()
.query(
&format!("SELECT id, n, flag, note FROM {table} ORDER BY id"),
&[],
)
.await
.unwrap()
.iter()
.map(|row| (row.get(0), row.get(1), row.get(2), row.get(3)))
.collect()
}
/// Queued writes go out ahead of the next statement, which sees them,
/// and with the `COMMIT` when nothing follows them.
#[test]
fn queued_writes_are_sent_in_order() {
let Some((rt, pool, table)) = setup() else {
return;
};
rt.block_on(async {
let seen = run_write_interaction(&pool, "test", |txn, _| {
let table = table.clone();
async move {
txn.insert_many_later(
&table,
vec![
OwnedColumn::new("id", "text", vec!["a".to_owned(), "b".to_owned()]),
OwnedColumn::new("n", "bigint", vec![1i64, 2]),
],
None,
)?;
txn.execute_later(
format!("UPDATE {table} SET n = n + 10 WHERE id = $1"),
vec![Box::new("a".to_owned())],
);
let rows = txn
.query(&format!("SELECT id, n FROM {table} ORDER BY id"), &[])
.await?;
// Only queued: sent with the COMMIT.
txn.upsert_many_later(
&table,
vec![OwnedColumn::new("id", "text", vec!["c".to_owned()])],
vec![OwnedColumn::new("n", "bigint", vec![3i64])],
)?;
Ok(rows
.iter()
.map(|row| (row.get::<_, String>(0), row.get::<_, i64>(1)))
.collect::<Vec<_>>())
}
.boxed()
})
.await
.unwrap();
assert_eq!(seen, vec![("a".to_owned(), 11), ("b".to_owned(), 2)]);
assert_eq!(
rows(&pool, &table).await,
vec![
("a".to_owned(), 11, None, None),
("b".to_owned(), 2, None, None),
("c".to_owned(), 3, None, None),
]
);
});
teardown(&rt, &pool, &table);
}
/// Several reads go out together, behind the queued writes, and come
/// back in order.
#[test]
fn query_many_reads_in_order_behind_queued_writes() {
let Some((rt, pool, table)) = setup() else {
return;
};
rt.block_on(async {
let seen = run_write_interaction(&pool, "test", |txn, _| {
let table = table.clone();
async move {
txn.insert_many_later(
&table,
vec![
OwnedColumn::new("id", "text", vec!["a".to_owned(), "b".to_owned()]),
OwnedColumn::new("n", "bigint", vec![1i64, 2]),
],
None,
)?;
let id = "b";
let by_id: [&(dyn ToSql + Sync); 1] = [&id];
let results = txn
.query_many(&[
(&format!("SELECT n FROM {table} ORDER BY id"), &[]),
(&format!("SELECT n FROM {table} WHERE id = $1"), &by_id),
("SELECT 42::bigint", &[]),
])
.await?;
Ok(results
.iter()
.map(|rows| rows.iter().map(|row| row.get::<_, i64>(0)).collect())
.collect::<Vec<Vec<i64>>>())
}
.boxed()
})
.await
.unwrap();
assert_eq!(seen, vec![vec![1, 2], vec![2], vec![42]]);
});
teardown(&rt, &pool, &table);
}
/// A queued write that fails surfaces at the next statement, as itself
/// (the next statement only fails because it aborted the transaction),
/// mapped as asked. Nothing is committed.
#[test]
fn queued_write_failure_surfaces_at_the_next_statement() {
let Some((rt, pool, table)) = setup() else {
return;
};
rt.block_on(async {
let error = run_write_interaction(&pool, "test", |txn, _| {
let table = table.clone();
async move {
txn.execute_later(
format!("INSERT INTO {table} (id, n) VALUES ('a', 1)"),
vec![],
);
txn.execute_later_mapping_error(
format!("INSERT INTO {table} (id, n) VALUES ('a', 2)"),
vec![],
Box::new(|error| match error {
WriteError::Constraint { .. } => {
WriteError::Other(anyhow::anyhow!("mapped duplicate"))
}
other => other,
}),
);
txn.query(&format!("SELECT id FROM {table}"), &[]).await?;
Ok(())
}
.boxed()
})
.await
.unwrap_err();
assert!(
format!("{error}").contains("mapped duplicate"),
"unexpected error: {error}"
);
assert_eq!(rows(&pool, &table).await, vec![]);
});
teardown(&rt, &pool, &table);
}
/// A queued write whose parameters do not encode stops everything queued
/// with it from being sent, the `COMMIT` included, so nothing is
/// committed.
#[test]
fn queued_write_that_does_not_encode_commits_nothing() {
let Some((rt, pool, table)) = setup() else {
return;
};
rt.block_on(async {
let result: Result<(), WriteError> = run_write_interaction(&pool, "test", |txn, _| {
let table = table.clone();
async move {
for (id, n) in [("x", "1"), ("y", "2"), ("z", "3")] {
let id: OwnedParam = if id == "y" {
// `id` is text: an integer does not encode.
Box::new(5i64)
} else {
Box::new(id.to_owned())
};
txn.execute_later(
format!("INSERT INTO {table} (id, n) VALUES ($1, {n})"),
vec![id],
);
}
Ok(())
}
.boxed()
})
.await;
let error = result.unwrap_err();
assert!(
format!("{error:#}").contains("error serializing parameter 0"),
"unexpected error: {error:#}"
);
assert_eq!(rows(&pool, &table).await, vec![]);
});
teardown(&rt, &pool, &table);
}
/// A dry run still sends its queued writes, so their failures surface.
#[test]
fn dry_run_checks_queued_writes() {
let Some((rt, pool, table)) = setup() else {
return;
};
rt.block_on(async {
let result = run_rolled_back_interaction(&pool, "test", |txn, _| {
let table = table.clone();
async move {
txn.execute_later(
format!("INSERT INTO {table} (id, n) VALUES ('a', NULL)"),
vec![],
);
Ok(())
}
.boxed()
})
.await;
assert!(
matches!(result, Err(WriteError::Constraint { .. })),
"unexpected result: {result:?}"
);
});
teardown(&rt, &pool, &table);
}
/// When the closure fails, its queued writes are dropped unsent.
#[test]
fn queued_writes_are_dropped_on_failure() {
let Some((rt, pool, table)) = setup() else {
return;
};
rt.block_on(async {
let result: Result<(), WriteError> = run_write_interaction(&pool, "test", |txn, _| {
let table = table.clone();
async move {
txn.execute_later(
format!("INSERT INTO {table} (id, n) VALUES ('a', 1)"),
vec![],
);
Err(WriteError::Other(anyhow::anyhow!("closure failed")))
}
.boxed()
})
.await;
assert!(result.is_err());
assert_eq!(rows(&pool, &table).await, vec![]);
});
teardown(&rt, &pool, &table);
}
/// The `BEGIN` goes out with the first statement, so that statement
/// runs inside the transaction, whether it is prepared afresh or comes
/// from the cache.
#[test]
fn first_statement_runs_in_the_transaction() {
let Some((rt, pool, table)) = setup() else {
return;
};
rt.block_on(async {
for _ in 0..6 {
let isolation = run_write_interaction(&pool, "test", |txn, _| {
async move {
let rows = txn
.query("SELECT current_setting('transaction_isolation')", &[])
.await?;
Ok(rows[0].get::<_, String>(0))
}
.boxed()
})
.await
.unwrap();
assert_eq!(isolation, "repeatable read");
}
});
teardown(&rt, &pool, &table);
}
/// A transaction that runs no statement sends neither `BEGIN` nor
/// `COMMIT`, and leaves the connection usable.
#[test]
fn empty_transaction_sends_nothing() {
let Some((rt, pool, table)) = setup() else {
return;
};
rt.block_on(async {
run_write_interaction(&pool, "test", |_txn, _| async move { Ok(()) }.boxed())
.await
.unwrap();
let count = run_write_interaction(&pool, "test", |txn, _| {
let table = table.clone();
async move {
let rows = txn
.query(&format!("SELECT COUNT(*) FROM {table}"), &[])
.await?;
Ok(rows[0].get::<_, i64>(0))
}
.boxed()
})
.await
.unwrap();
assert_eq!(count, 0);
});
teardown(&rt, &pool, &table);
}
#[test]
fn insert_and_upsert_many() {
let Some((rt, pool, table)) = setup() else {
return;
};
rt.block_on(async {
let ids = vec!["a".to_string(), "b".to_string()];
let ns = vec![1i64, 2];
let flags = vec![Some(true), None];
let notes: Vec<Option<String>> = vec![None, Some("x".into())];
run_write_interaction(&pool, "insert", |txn, _| {
let table = &table;
let (ids, ns, flags, notes) = (&ids, &ns, &flags, &notes);
async move {
txn.insert_many(
table,
&[
Column::new("id", "text", ids),
Column::new("n", "bigint", ns),
Column::new("flag", "boolean", flags),
Column::new("note", "text", notes),
],
None,
)
.await
}
.boxed()
})
.await
.unwrap();
// Update one row, add another; an empty batch does nothing.
let ids = vec!["b".to_string(), "c".to_string()];
let ns = vec![20i64, 30];
let empty: Vec<String> = Vec::new();
run_write_interaction(&pool, "upsert", |txn, _| {
let table = &table;
let (ids, ns, empty) = (&ids, &ns, &empty);
async move {
txn.upsert_many(
table,
&[Column::new("id", "text", ids)],
&[Column::new("n", "bigint", ns)],
)
.await?;
let inserted = txn
.insert_many(table, &[Column::new("id", "text", empty)], None)
.await?;
assert_eq!(inserted, 0);
Ok(())
}
.boxed()
})
.await
.unwrap();
assert_eq!(
rows(&pool, &table).await,
vec![
("a".into(), 1, Some(true), None),
("b".into(), 20, None, Some("x".into())),
("c".into(), 30, None, None),
]
);
});
teardown(&rt, &pool, &table);
}
#[test]
fn mismatched_columns_are_refused() {
let ids = vec!["a".to_string()];
let ns = vec![1i64, 2];
let columns = [
Column::new("id", "text", &ids),
Column::new("n", "bigint", &ns),
];
let shapes: Vec<(&str, &str, usize)> = columns
.iter()
.map(|c| (c.name, c.sql_type, c.rows))
.collect();
assert!(check_batch("t", &shapes).is_err());
}
#[test]
fn constraint_violation_is_typed_and_rolls_back() {
let Some((rt, pool, table)) = setup() else {
return;
};
rt.block_on(async {
let ids = vec!["a".to_string(), "a".to_string()];
let ns = vec![1i64, 2];
let result = run_write_interaction(&pool, "duplicate", |txn, _| {
let table = &table;
let (ids, ns) = (&ids, &ns);
async move {
txn.execute_batch(
&format!("INSERT INTO {table} (id, n) VALUES ($1, $2)"),
&[vec![&ids[0], &ns[0]]],
)
.await?;
txn.insert_many(
table,
&[
Column::new("id", "text", ids),
Column::new("n", "bigint", ns),
],
None,
)
.await
}
.boxed()
})
.await;
match result {
Err(WriteError::Constraint { sqlstate, .. }) => assert_eq!(sqlstate, "23505"),
other => panic!("expected a constraint violation, got {other:?}"),
}
// The first insert was rolled back with the rest.
assert!(rows(&pool, &table).await.is_empty());
});
teardown(&rt, &pool, &table);
}
#[test]
fn caught_statement_error_does_not_commit() {
let Some((rt, pool, table)) = setup() else {
return;
};
rt.block_on(async {
let result = run_write_interaction(&pool, "caught", |txn, _| {
let table = &table;
async move {
txn.execute(&format!("INSERT INTO {table} (id, n) VALUES ('a', 1)"), &[])
.await?;
// Postgres aborts the transaction here, so ignoring the
// error must not let the first insert look committed.
let duplicate = txn
.execute(&format!("INSERT INTO {table} (id, n) VALUES ('a', 2)"), &[])
.await;
assert!(matches!(duplicate, Err(WriteError::Constraint { .. })));
Ok(())
}
.boxed()
})
.await;
assert!(matches!(result, Err(WriteError::Other(_))), "{result:?}");
assert!(rows(&pool, &table).await.is_empty());
});
teardown(&rt, &pool, &table);
}
#[test]
fn serialization_failure_is_retried() {
let Some((rt, pool, table)) = setup() else {
return;
};
rt.block_on(async {
pool.get()
.await
.unwrap()
.batch_execute(&format!("INSERT INTO {table} (id, n) VALUES ('a', 0)"))
.await
.unwrap();
// On the first two attempts, another transaction updates the row
// after ours has taken its snapshot, so our update fails with a
// serialization failure.
let attempts = Arc::new(AtomicU32::new(0));
let result = run_write_interaction(&pool, "conflict", |txn, _| {
let (pool, table, attempts) = (&pool, &table, Arc::clone(&attempts));
async move {
let attempt = attempts.fetch_add(1, Ordering::SeqCst) + 1;
txn.query(&format!("SELECT n FROM {table} WHERE id = 'a'"), &[])
.await?;
if attempt <= 2 {
pool.get()
.await
.unwrap()
.batch_execute(&format!("UPDATE {table} SET n = n + 10 WHERE id = 'a'"))
.await
.unwrap();
}
txn.execute(&format!("UPDATE {table} SET n = n + 1 WHERE id = 'a'"), &[])
.await?;
Ok(attempt)
}
.boxed()
})
.await
.unwrap();
assert_eq!(result, 3);
assert_eq!(rows(&pool, &table).await[0].1, 21);
});
teardown(&rt, &pool, &table);
}
/// The spans of a traced write interaction: Python's `db.{name}` →
/// `db.connection` → `db.txn` → `db.query` tree, one `db.txn` per
/// attempt, and one `db.query` per round trip.
#[test]
fn traced_interaction_with_a_retry() {
use crate::logging::opentracing::layer::testing::{tag, traced};
use crate::logging::opentracing::thrift::TagValue;
use crate::logging::opentracing::SamplerConfig;
let Some((rt, pool, table)) = setup() else {
return;
};
let (_, spans) = traced(SamplerConfig::Const(true), || {
rt.block_on(async {
pool.get()
.await
.unwrap()
.batch_execute(&format!("INSERT INTO {table} (id, n) VALUES ('a', 0)"))
.await
.unwrap();
// The first attempt hits a serialization failure.
let attempts = Arc::new(AtomicU32::new(0));
run_write_interaction(&pool, "conflict", |txn, _| {
let (pool, table, attempts) = (&pool, &table, Arc::clone(&attempts));
async move {
let attempt = attempts.fetch_add(1, Ordering::SeqCst) + 1;
txn.query(&format!("SELECT n FROM {table} WHERE id = 'a'"), &[])
.await?;
if attempt == 1 {
pool.get()
.await
.unwrap()
.batch_execute(&format!(
"UPDATE {table} SET n = n + 10 WHERE id = 'a'"
))
.await
.unwrap();
}
txn.execute_later(
format!("UPDATE {table}\n SET n = n + 1\n WHERE id = 'a'"),
vec![],
);
txn.flush().await?;
Ok(())
}
.boxed()
})
.await
.unwrap();
})
});
teardown(&rt, &pool, &table);
let named = |name: &str| -> Vec<&FinishedSpan> {
spans.iter().filter(|span| span.operation == name).collect()
};
let [interaction] = named("db.conflict")[..] else {
panic!("expected one db.conflict span: {spans:#?}")
};
assert_eq!(interaction.parent_id, 0);
let connections = named("db.connection");
assert_eq!(connections.len(), 2);
for connection in &connections {
assert_eq!(connection.parent_id, interaction.span_id);
}
// The retry log says why.
let retry = &interaction.logs[1].fields;
assert!(retry.contains(&Tag::new("attempt", TagValue::Long(1))));
assert!(retry.iter().any(|field| field.key == "error"));
let txns = named("db.txn");
assert_eq!(txns.len(), 2);
// Both attempts are one transaction to Python's way of naming them.
assert_eq!(tag(txns[0], "db.txn_id"), tag(txns[1], "db.txn_id"));
// The failed attempt is marked as Python marks a span an exception
// left, and so is the round trip that failed in it.
assert_eq!(tag(txns[0], "error"), Some(&TagValue::Bool(true)));
assert_eq!(tag(txns[1], "error"), None);
assert!(named("db.query").iter().any(|query| {
query.parent_id == txns[0].span_id && tag(query, "error") == Some(&TagValue::Bool(true))
}));
assert_eq!(tag(interaction, "error"), None);
let attempts: Vec<_> = txns.iter().map(|txn| tag(txn, "db.attempt")).collect();
assert_eq!(
attempts,
vec![Some(&TagValue::Long(1)), Some(&TagValue::Long(2))]
);
assert_eq!(
tag(txns[0], "db.txn_desc"),
Some(&TagValue::Str("conflict".into()))
);
assert_eq!(txns[0].parent_id, connections[0].span_id);
assert_eq!(txns[1].parent_id, connections[1].span_id);
// Checked out on the interaction's span, not the connection's.
assert_eq!(interaction.logs.len(), 3, "two checkouts and a retry");
// The failed attempt rolled back; the second committed.
let [rollback] = named("db.rollback")[..] else {
panic!("expected one db.rollback span: {spans:#?}")
};
assert_eq!(rollback.parent_id, txns[0].span_id);
let commit_logged = txns[1].logs.iter().any(|log| {
log.fields
.iter()
.any(|field| field.value == TagValue::Str("commit".into()))
});
assert!(commit_logged);
// Second attempt: the SELECT (with the BEGIN), the flush of the
// queued UPDATE, and the COMMIT, one span per round trip.
let queries: Vec<_> = named("db.query")
.into_iter()
.filter(|query| query.parent_id == txns[1].span_id)
.collect();
assert_eq!(queries.len(), 3, "{queries:#?}");
assert_eq!(tag(queries[0], "db.begin"), Some(&TagValue::Bool(true)));
assert_eq!(tag(queries[0], "db.queued"), Some(&TagValue::Long(0)));
assert_eq!(
tag(queries[0], "db.statement"),
Some(&TagValue::Str(
format!("SELECT n FROM {table} WHERE id = 'a'").into()
))
);
assert_eq!(tag(queries[1], "db.begin"), Some(&TagValue::Bool(false)));
assert_eq!(tag(queries[1], "db.queued"), Some(&TagValue::Long(1)));
assert_eq!(tag(queries[1], "db.statement"), None);
assert_eq!(
tag(queries[1], "db.queued_statements"),
Some(&TagValue::Str(
format!("UPDATE {table} SET n = n + 1 WHERE id = 'a'").into()
))
);
assert_eq!(
tag(queries[2], "db.statement"),
Some(&TagValue::Str("COMMIT".into()))
);
// The first SELECT was prepared in a round trip of its own.
assert!(named("db.prepare")
.iter()
.any(|prepare| { tag(prepare, "db.prepared") == Some(&TagValue::Long(1)) }));
}
/// Under an unsampled Python parent, an interaction reports nothing, even
/// with a sampler that would sample a trace starting in Rust: its spans
/// follow the parent's decision.
#[test]
fn interaction_under_an_unsampled_parent_reports_nothing() {
use crate::logging::context::testing::handle_with_span;
use crate::logging::opentracing::layer::testing::traced;
use crate::logging::opentracing::{SamplerConfig, SpanContext};
let Some((rt, pool, table)) = setup() else {
return;
};
let parent = SpanContext {
trace_id: 5,
span_id: 6,
flags: 0,
baggage: None,
};
let (_, spans) = traced(SamplerConfig::Const(true), || {
rt.block_on(handle_with_span(Some(parent)).scope(async {
run_write_interaction(&pool, "quiet", |txn, _| {
let table = table.clone();
async move {
txn.execute(&format!("INSERT INTO {table} (id, n) VALUES ('q', 1)"), &[])
.await?;
Ok(())
}
.boxed()
})
.await
.unwrap();
}))
});
teardown(&rt, &pool, &table);
assert!(spans.is_empty());
}
#[test]
fn write_errors_survive_anyhow() {
let through = |error: WriteError| -> WriteError {
let error: anyhow::Result<()> = Err(anyhow::Error::new(error));
error.context("reading the chain links").unwrap_err().into()
};
assert!(matches!(
through(WriteError::Conflict("simulated".into())),
WriteError::Conflict(_)
));
assert!(matches!(
through(WriteError::Constraint {
sqlstate: "23505".into(),
message: "simulated".into()
}),
WriteError::Constraint { sqlstate, .. } if sqlstate == "23505"
));
assert!(matches!(
through(WriteError::ConnectionLost("simulated".into())),
WriteError::ConnectionLost(_)
));
assert!(matches!(
through(WriteError::Cancelled("simulated".into())),
WriteError::Cancelled(_)
));
// Anything else keeps its context.
let WriteError::Other(error) = through(WriteError::Other(anyhow::anyhow!("boom"))) else {
panic!("expected Other");
};
assert_eq!(format!("{error:#}"), "reading the chain links: boom");
}
#[test]
fn retryable_errors_are_found_in_the_chain() {
// A write interaction has already retried its own errors.
let wrapped = |error: WriteError| anyhow::Error::new(error).context("reading");
assert!(!is_retryable_error(&wrapped(WriteError::Conflict(
"x".into()
))));
assert!(!is_retryable_error(&wrapped(WriteError::Cancelled(
"x".into()
))));
assert!(!is_retryable_error(&anyhow::anyhow!("x")));
let Some((rt, pool, table)) = setup() else {
return;
};
rt.block_on(async {
let conn = pool.get().await.unwrap();
conn.batch_execute("SET statement_timeout = 1")
.await
.unwrap();
let cancelled = conn.query("SELECT pg_sleep(1)", &[]).await.unwrap_err();
conn.batch_execute("RESET statement_timeout").await.unwrap();
let cancelled = anyhow::Error::new(cancelled).context("running a query");
assert!(is_retryable_error(&cancelled), "{cancelled:#}");
let error = conn.query("SELECT 1/0", &[]).await.unwrap_err();
assert!(!is_retryable_error(&anyhow::Error::new(error)));
});
teardown(&rt, &pool, &table);
}
#[test]
fn serialization_failure_through_querier_is_retried() {
let Some((rt, pool, table)) = setup() else {
return;
};
rt.block_on(async {
pool.get()
.await
.unwrap()
.batch_execute(&format!("INSERT INTO {table} (id, n) VALUES ('a', 0)"))
.await
.unwrap();
// As in `serialization_failure_is_retried`, with the statements
// going through `Querier` and `anyhow`.
let attempts = Arc::new(AtomicU32::new(0));
let result = run_write_interaction(&pool, "conflict", |txn, _| {
let (pool, table, attempts) = (&pool, &table, Arc::clone(&attempts));
async move {
let querier: &dyn Querier = txn;
let attempt = attempts.fetch_add(1, Ordering::SeqCst) + 1;
querier
.query(&format!("SELECT n FROM {table} WHERE id = 'a'"), &[])
.await?;
if attempt == 1 {
pool.get()
.await
.unwrap()
.batch_execute(&format!("UPDATE {table} SET n = n + 10 WHERE id = 'a'"))
.await
.unwrap();
}
querier
.query(
&format!("UPDATE {table} SET n = n + 1 WHERE id = 'a' RETURNING n"),
&[],
)
.await
.context("bumping n")?;
Ok(attempt)
}
.boxed()
})
.await
.unwrap();
assert_eq!(result, 2);
assert_eq!(rows(&pool, &table).await[0].1, 11);
});
teardown(&rt, &pool, &table);
}
#[test]
fn caught_serialization_failure_is_retried() {
let Some((rt, pool, table)) = setup() else {
return;
};
rt.block_on(async {
pool.get()
.await
.unwrap()
.batch_execute(&format!("INSERT INTO {table} (id, n) VALUES ('a', 0)"))
.await
.unwrap();
// The first attempt hits a serialization failure and swallows
// it. The commit is refused, and as the error was retryable the
// whole interaction runs again.
let attempts = Arc::new(AtomicU32::new(0));
let result = run_write_interaction(&pool, "caught conflict", |txn, _| {
let (pool, table, attempts) = (&pool, &table, Arc::clone(&attempts));
async move {
let attempt = attempts.fetch_add(1, Ordering::SeqCst) + 1;
txn.query(&format!("SELECT n FROM {table} WHERE id = 'a'"), &[])
.await?;
if attempt == 1 {
pool.get()
.await
.unwrap()
.batch_execute(&format!("UPDATE {table} SET n = n + 10 WHERE id = 'a'"))
.await
.unwrap();
}
let update = txn
.execute(&format!("UPDATE {table} SET n = n + 1 WHERE id = 'a'"), &[])
.await;
if attempt == 1 {
assert!(matches!(update, Err(WriteError::Conflict(_))), "{update:?}");
} else {
update?;
}
Ok(attempt)
}
.boxed()
})
.await
.unwrap();
assert_eq!(result, 2);
assert_eq!(rows(&pool, &table).await[0].1, 11);
});
teardown(&rt, &pool, &table);
}
#[test]
fn retries_stop_after_five_attempts() {
let Some((rt, pool, table)) = setup() else {
return;
};
rt.block_on(async {
let attempts = AtomicU32::new(0);
let result: Result<(), _> = run_write_interaction(&pool, "always", |_txn, _| {
attempts.fetch_add(1, Ordering::SeqCst);
async { Err(WriteError::Conflict("simulated".into())) }.boxed()
})
.await;
assert!(matches!(result, Err(WriteError::Conflict(_))));
assert_eq!(attempts.load(Ordering::SeqCst), MAX_ATTEMPTS);
// Other errors are not retried.
attempts.store(0, Ordering::SeqCst);
let result: Result<(), _> = run_write_interaction(&pool, "other", |_txn, _| {
attempts.fetch_add(1, Ordering::SeqCst);
async { Err(anyhow::anyhow!("boom").into()) }.boxed()
})
.await;
assert!(matches!(result, Err(WriteError::Other(_))));
assert_eq!(attempts.load(Ordering::SeqCst), 1);
});
teardown(&rt, &pool, &table);
}
}