#![cfg_attr(
not(test),
deny(
clippy::unwrap_used,
clippy::expect_used,
clippy::panic,
clippy::unreachable,
clippy::todo,
clippy::unimplemented,
clippy::indexing_slicing,
)
)]
use std::collections::HashMap;
use std::sync::Arc;
use std::sync::atomic::{AtomicUsize, Ordering};
use diesel::ConnectionError;
use diesel_async::AsyncPgConnection;
use diesel_async::pooled_connection::deadpool::Pool;
use diesel_async::pooled_connection::{
AsyncDieselConnectionManager, ManagerConfig, RecyclingMethod,
};
use futures::FutureExt as _;
use tokio::io::{AsyncRead, AsyncReadExt as _, AsyncWrite, AsyncWriteExt as _};
use crate::capsule::replay::{Divergence, DivergenceKind, DivergenceLog, TapeProgress};
use crate::capsule::schema::{BindValue, Capsule, ConnectionTape, Exchange};
use crate::capsule::wire::{
self, FrameSplitter, FrontendMessage, build, is_catalog_sql, parse_frontend,
};
use crate::db::PoolError;
const DIVERGENCE_SQLSTATE: &str = "58000";
const DUPLEX_CAPACITY: usize = 64 * 1024;
const READ_CHUNK: usize = 8 * 1024;
const REPLAY_CHECKOUT_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(5);
const REPLAY_URL: &str = "postgres://replay@127.0.0.1:1/replay";
fn close_complete() -> Vec<u8> {
vec![b'3', 0, 0, 0, 4]
}
fn is_housekeeping(sql: &str) -> bool {
wire::is_session_housekeeping(sql)
}
fn split_statements(sql: &str) -> Vec<&str> {
wire::split_statements(sql)
.into_iter()
.map(str::trim)
.filter(|statement| !statement.is_empty())
.collect()
}
pub struct StubServer {
tape: ConnectionTape,
divergences: Arc<DivergenceLog>,
progress: Arc<TapeProgress>,
statements: HashMap<String, String>,
portals: HashMap<String, String>,
}
enum Resolution {
Recorded(Vec<u8>),
Diverged(Divergence),
}
impl StubServer {
#[must_use]
pub fn new(tape: ConnectionTape, divergences: Arc<DivergenceLog>) -> Self {
let progress = divergences.register_tape(&tape);
Self::with_progress(tape, divergences, progress)
}
fn with_progress(
tape: ConnectionTape,
divergences: Arc<DivergenceLog>,
progress: Arc<TapeProgress>,
) -> Self {
Self {
tape,
divergences,
progress,
statements: HashMap::new(),
portals: HashMap::new(),
}
}
pub async fn serve<S>(
stream: S,
tape: ConnectionTape,
divergences: Arc<DivergenceLog>,
progress: Arc<TapeProgress>,
) where
S: AsyncRead + AsyncWrite + Unpin + Send,
{
let mut server = Self::with_progress(tape, divergences, progress);
let _ = server.run(stream).await;
}
async fn run<S>(&mut self, mut stream: S) -> std::io::Result<()>
where
S: AsyncRead + AsyncWrite + Unpin + Send,
{
let mut splitter = FrameSplitter::new_frontend();
let mut batch: Vec<FrontendMessage> = Vec::new();
let mut chunk = [0u8; READ_CHUNK];
loop {
let read = stream.read(&mut chunk).await?;
if read == 0 {
return Ok(());
}
let Some(bytes) = chunk.get(..read) else {
return Ok(());
};
for frame in splitter.push(bytes) {
let message = parse_frontend(&frame);
let reply = match message {
FrontendMessage::SslRequest => Some(vec![b'N']),
FrontendMessage::Startup => Some(self.handshake()),
FrontendMessage::Terminate => return Ok(()),
FrontendMessage::Query(sql) => Some(self.simple_query(&sql)),
FrontendMessage::Sync => {
batch.push(FrontendMessage::Sync);
let reply = self.extended_batch(&batch);
batch.clear();
Some(reply)
}
FrontendMessage::Flush => None,
other => {
batch.push(other);
None
}
};
if let Some(reply) = reply {
stream.write_all(&reply).await?;
stream.flush().await?;
}
}
if splitter.is_unrecordable() {
return Ok(());
}
}
}
fn handshake(&self) -> Vec<u8> {
if let Some(recorded) = self
.tape
.prologue
.first()
.filter(|exchange| exchange.sql.is_empty() && !exchange.response.is_empty())
{
return recorded.response.clone();
}
let mut reply = build::authentication_ok();
for (key, value) in [
("server_version", "16.0"),
("client_encoding", "UTF8"),
("DateStyle", "ISO, MDY"),
("integer_datetimes", "on"),
("TimeZone", "UTC"),
("standard_conforming_strings", "on"),
] {
reply.extend_from_slice(&build::parameter_status(key, value));
}
reply.extend_from_slice(&build::backend_key_data(1, 1));
reply.extend_from_slice(&build::ready_for_query(b'I'));
reply
}
fn simple_query(&self, sql: &str) -> Vec<u8> {
if split_statements(sql).is_empty() {
let mut reply = build::empty_query_response();
reply.extend_from_slice(&build::ready_for_query(b'I'));
return reply;
}
if is_housekeeping(sql) {
let mut reply = Vec::new();
for _ in split_statements(sql) {
reply.extend_from_slice(&build::command_complete("SET"));
}
reply.extend_from_slice(&build::ready_for_query(b'I'));
return reply;
}
match self.resolve_execute(sql, &[]) {
Resolution::Recorded(bytes) => bytes,
Resolution::Diverged(divergence) => self.diverge(divergence),
}
}
fn extended_batch(&mut self, batch: &[FrontendMessage]) -> Vec<u8> {
let mut parse_sql = None;
let mut bind: Option<(String, Vec<Option<Vec<u8>>>)> = None;
let mut describe_sql = None;
let mut has_describe = false;
let mut has_execute = false;
let mut unknown_statement: Option<String> = None;
for message in batch {
match message {
FrontendMessage::Parse { name, sql, .. } => {
self.statements.insert(name.clone(), sql.clone());
parse_sql = Some(sql.clone());
}
FrontendMessage::Bind {
portal,
statement,
params,
} => {
let Some(sql) = self.statements.get(statement).cloned() else {
unknown_statement = Some(statement.clone());
continue;
};
self.portals.insert(portal.clone(), sql.clone());
bind = Some((sql, params.clone()));
}
FrontendMessage::Describe { kind, name } => {
has_describe = true;
describe_sql = if *kind == b'S' {
self.statements.get(name).cloned()
} else {
self.portals.get(name).cloned()
};
}
FrontendMessage::Execute => has_execute = true,
_ => {}
}
}
if let Some(name) = unknown_statement {
return self.diverge(self.divergence(
DivergenceKind::UnknownStatement,
None,
"",
format!(
"the code bound prepared statement {name:?}, which this connection never parsed during the replay; the capsule cannot say what it was"
),
));
}
let sql = bind
.as_ref()
.map(|(sql, _)| sql.clone())
.or_else(|| parse_sql.clone())
.or_else(|| describe_sql.clone());
let Some(sql) = sql else {
return synthesize(batch, None);
};
if is_housekeeping(&sql) {
return synthesize(batch, Some("SET"));
}
let resolution = if bind.is_some() || has_execute {
let params = bind.map(|(_, params)| params).unwrap_or_default();
self.resolve_execute(&sql, ¶ms)
} else if has_describe {
self.resolve_prepare(&sql)
} else {
return synthesize(batch, None);
};
match resolution {
Resolution::Recorded(bytes) => bytes,
Resolution::Diverged(divergence) => self.diverge(divergence),
}
}
fn resolve_prepare(&self, sql: &str) -> Resolution {
if let Some(exchange) = find_by_sql(&self.tape.statements, sql) {
return Resolution::Recorded(exchange.response.clone());
}
if let Some(exchange) = find_by_sql(&self.tape.catalog, sql) {
return Resolution::Recorded(exchange.response.clone());
}
if self.tape_mentions(sql) {
return Resolution::Diverged(self.divergence(
DivergenceKind::UnknownStatement,
None,
sql,
format!(
"the capsule records executions of {sql:?} but no Parse/Describe metadata \
for it, so the replayed driver cannot prepare the statement"
),
));
}
Resolution::Diverged(self.divergence(
DivergenceKind::UnrecordedQuery,
None,
sql,
unrecorded_detail(sql),
))
}
fn resolve_execute(&self, sql: &str, params: &[Option<Vec<u8>>]) -> Resolution {
let expected = self.tape.exchanges.get(self.progress.consumed());
if let Some(expected) = expected.filter(|expected| expected.sql == sql) {
if !binds_match(&expected.binds, params) {
let expected_binds = describe_binds(&expected.binds);
let actual_binds = describe_params(params);
let expected_sql = expected.sql.clone();
return Resolution::Diverged(self.divergence(
DivergenceKind::BindMismatch,
Some(expected_sql),
sql,
format!(
"{sql:?} was recorded with binds {expected_binds} but the code bound \
{actual_binds}"
),
));
}
let response = expected.response.clone();
self.progress.advance();
return Resolution::Recorded(response);
}
if let Some(exchange) = find_by_sql(&self.tape.prologue, sql) {
return Resolution::Recorded(exchange.response.clone());
}
if let Some(exchange) = self
.tape
.catalog
.iter()
.find(|exchange| exchange.sql == sql && binds_match(&exchange.binds, params))
{
return Resolution::Recorded(exchange.response.clone());
}
let Some(expected) = expected else {
if is_catalog_sql(sql) {
return Resolution::Diverged(self.divergence(
DivergenceKind::UnrecordedQuery,
None,
sql,
unrecorded_detail(sql),
));
}
return Resolution::Diverged(self.divergence(
DivergenceKind::TapeExhausted,
None,
sql,
format!(
"the connection's tape holds {} exchange(s) and they have all been replayed, \
but the code asked for {sql:?}",
self.tape.exchanges.len()
),
));
};
let kind = if self.tape_mentions(sql) {
DivergenceKind::SqlMismatch
} else {
DivergenceKind::UnrecordedQuery
};
let expected_sql = expected.sql.clone();
let detail = if kind == DivergenceKind::SqlMismatch {
format!(
"the tape expected {expected_sql:?} next but the code sent {sql:?}; the \
statements have been reordered since the recording"
)
} else {
unrecorded_detail(sql)
};
Resolution::Diverged(self.divergence(kind, Some(expected_sql), sql, detail))
}
fn tape_mentions(&self, sql: &str) -> bool {
[
&self.tape.exchanges,
&self.tape.prologue,
&self.tape.statements,
&self.tape.catalog,
]
.into_iter()
.any(|list| list.iter().any(|exchange| exchange.sql == sql))
}
fn divergence(
&self,
kind: DivergenceKind,
expected_sql: Option<String>,
actual_sql: &str,
detail: String,
) -> Divergence {
Divergence {
kind,
connection: self.tape.id,
exchange_index: self.progress.consumed(),
expected_sql,
actual_sql: actual_sql.to_owned(),
detail,
}
}
fn diverge(&self, divergence: Divergence) -> Vec<u8> {
let mut reply = build::error_response(
DIVERGENCE_SQLSTATE,
&format!("autumn_replay_divergence: {}", divergence.detail),
);
reply.extend_from_slice(&build::ready_for_query(b'I'));
self.divergences.record(divergence);
reply
}
}
fn synthesize(batch: &[FrontendMessage], command_tag: Option<&str>) -> Vec<u8> {
let mut reply = Vec::new();
for message in batch {
match message {
FrontendMessage::Parse { .. } => reply.extend_from_slice(&build::parse_complete()),
FrontendMessage::Bind { .. } => reply.extend_from_slice(&build::bind_complete()),
FrontendMessage::Describe { kind, .. } => {
if *kind == b'S' {
reply.extend_from_slice(&build::parameter_description(&[]));
}
reply.extend_from_slice(&build::no_data());
}
FrontendMessage::Execute => {
reply.extend_from_slice(&build::command_complete(command_tag.unwrap_or("SET")));
}
FrontendMessage::Close { .. } => reply.extend_from_slice(&close_complete()),
FrontendMessage::Sync => reply.extend_from_slice(&build::ready_for_query(b'I')),
_ => {}
}
}
reply
}
fn unrecorded_detail(sql: &str) -> String {
if is_catalog_sql(sql) {
format!(
"the code sent the driver catalog probe {sql:?}, which the capsule did not record; a \
custom or extension type whose OID was not in the recorded connection's type cache \
cannot be resolved offline"
)
} else {
format!(
"the code sent {sql:?}, which the capsule never recorded on this connection; the \
query path has changed since the capsule was captured"
)
}
}
fn find_by_sql<'a>(exchanges: &'a [Exchange], sql: &str) -> Option<&'a Exchange> {
exchanges.iter().find(|exchange| exchange.sql == sql)
}
fn binds_match(recorded: &[BindValue], actual: &[Option<Vec<u8>>]) -> bool {
recorded.len() == actual.len()
&& recorded
.iter()
.zip(actual)
.all(|(recorded, actual)| match recorded {
BindValue::Masked => true,
BindValue::Null => actual.is_none(),
BindValue::Value(bytes) => actual.as_deref() == Some(bytes.as_slice()),
})
}
fn describe_binds(binds: &[BindValue]) -> String {
let rendered: Vec<String> = binds
.iter()
.map(|bind| match bind {
BindValue::Null => "NULL".to_owned(),
BindValue::Masked => "[FILTERED]".to_owned(),
BindValue::Value(bytes) => hex_preview(bytes),
})
.collect();
format!("[{}]", rendered.join(", "))
}
fn describe_params(params: &[Option<Vec<u8>>]) -> String {
let rendered: Vec<String> = params
.iter()
.map(|param| {
param
.as_deref()
.map_or_else(|| "NULL".to_owned(), hex_preview)
})
.collect();
format!("[{}]", rendered.join(", "))
}
fn hex_preview(bytes: &[u8]) -> String {
use std::fmt::Write as _;
const MAX: usize = 16;
let head = bytes.get(..MAX.min(bytes.len())).unwrap_or_default();
let mut rendered = String::with_capacity(head.len().saturating_mul(2).saturating_add(16));
rendered.push_str("0x");
for byte in head {
let _ = write!(rendered, "{byte:02x}");
}
if bytes.len() > MAX {
let _ = write!(rendered, "… ({} bytes)", bytes.len());
}
rendered
}
pub fn pool_from_capsule(
capsule: &Capsule,
divergences: Arc<DivergenceLog>,
) -> Result<Pool<AsyncPgConnection>, PoolError> {
let tapes = capsule
.db
.as_ref()
.map(|db| {
db.connections
.iter()
.filter(|tape| tape.role != crate::capsule::schema::TAPE_ROLE_REPLICA)
.cloned()
.collect()
})
.unwrap_or_default();
pool_from_tapes(tapes, divergences)
}
pub fn replica_pool_from_capsule(
capsule: &Capsule,
divergences: Arc<DivergenceLog>,
) -> Result<Option<Pool<AsyncPgConnection>>, PoolError> {
let tapes: Vec<ConnectionTape> = capsule
.db
.as_ref()
.map(|db| {
db.connections
.iter()
.filter(|tape| tape.role == crate::capsule::schema::TAPE_ROLE_REPLICA)
.cloned()
.collect()
})
.unwrap_or_default();
let replica_configured = capsule
.db_roles
.iter()
.any(|role| role == crate::capsule::schema::TAPE_ROLE_REPLICA);
if tapes.is_empty() && !replica_configured {
return Ok(None);
}
pool_from_tapes(tapes, divergences).map(Some)
}
fn pool_from_tapes(
tapes: Vec<ConnectionTape>,
divergences: Arc<DivergenceLog>,
) -> Result<Pool<AsyncPgConnection>, PoolError> {
let tapes: Arc<Vec<ConnectionTape>> = Arc::new(tapes);
let max_size = tapes.len().saturating_add(1);
let next_tape = Arc::new(AtomicUsize::new(0));
let progress: Arc<Vec<Arc<TapeProgress>>> = Arc::new(
tapes
.iter()
.map(|tape| divergences.register_tape(tape))
.collect(),
);
let mut config = ManagerConfig::<AsyncPgConnection>::default();
config.recycling_method = RecyclingMethod::Fast;
config.custom_setup = Box::new(move |_url: &str| {
let tapes = Arc::clone(&tapes);
let next_tape = Arc::clone(&next_tape);
let divergences = Arc::clone(&divergences);
let progress = Arc::clone(&progress);
async move {
let index = next_tape.fetch_add(1, Ordering::SeqCst);
let tape = tapes.get(index).cloned().unwrap_or_default();
let progress = progress.get(index).map_or_else(
|| Arc::new(TapeProgress::new(tape.id, Vec::new())),
Arc::clone,
);
let (client_half, server_half) = tokio::io::duplex(DUPLEX_CAPACITY);
tokio::spawn(StubServer::serve(server_half, tape, divergences, progress));
let mut pg = tokio_postgres::Config::new();
pg.ssl_mode(tokio_postgres::config::SslMode::Disable)
.user("replay")
.dbname("replay");
let (client, connection) = pg
.connect_raw(client_half, tokio_postgres::NoTls)
.await
.map_err(|error| {
ConnectionError::BadConnection(format!(
"the replay stub server refused the handshake: {error}"
))
})?;
tokio::spawn(async move {
let _ = connection.await;
});
AsyncPgConnection::try_from(client).await
}
.boxed()
});
let manager =
AsyncDieselConnectionManager::<AsyncPgConnection>::new_with_config(REPLAY_URL, config);
Ok(Pool::builder(manager)
.max_size(max_size)
.wait_timeout(Some(REPLAY_CHECKOUT_TIMEOUT))
.create_timeout(Some(REPLAY_CHECKOUT_TIMEOUT))
.runtime(deadpool::Runtime::Tokio1)
.build()?)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::capsule::schema::ExchangeProtocol;
fn exchange(sql: &str, binds: Vec<BindValue>) -> Exchange {
Exchange {
protocol: ExchangeProtocol::Extended,
sql: sql.to_owned(),
binds,
response: build::ready_for_query(b'I'),
row_count: 0,
error: None,
}
}
#[tokio::test]
async fn tapes_are_split_by_recorded_role() {
use crate::capsule::schema::{CapsuleOutcome, ConnectionTape, test_support};
let mut capsule = test_support::capsule(
test_support::request("GET", "/split"),
CapsuleOutcome::Status {
code: 500,
message: String::new(),
problem_type: None,
},
);
capsule.db = Some(crate::capsule::schema::CapsuleDb {
connections: vec![
ConnectionTape {
id: 1,
role: crate::capsule::schema::TAPE_ROLE_PRIMARY.to_owned(),
..ConnectionTape::default()
},
ConnectionTape {
id: 2,
role: crate::capsule::schema::TAPE_ROLE_REPLICA.to_owned(),
..ConnectionTape::default()
},
],
});
let divergences = Arc::new(DivergenceLog::new());
let replica = replica_pool_from_capsule(&capsule, Arc::clone(&divergences))
.expect("replica pool builds");
assert!(
replica.is_some(),
"a capsule with replica-recorded tapes must get a replica stub pool"
);
capsule
.db
.as_mut()
.expect("db present")
.connections
.retain(|tape| tape.role != crate::capsule::schema::TAPE_ROLE_REPLICA);
let no_replica =
replica_pool_from_capsule(&capsule, divergences).expect("replica pool builds");
assert!(
no_replica.is_none(),
"no replica tapes — including a pre-role capsule — means no replica pool"
);
}
#[test]
fn framework_housekeeping_is_recognized() {
assert!(is_housekeeping("SET TIME ZONE 'UTC'"));
assert!(is_housekeeping("SET CLIENT_ENCODING TO 'UTF8'"));
assert!(is_housekeeping("SET statement_timeout = 5000"));
assert!(is_housekeeping(
"SET statement_timeout = 0; SET autumn.capsule_request = 'req-1'"
));
assert!(!is_housekeeping("SELECT 1"));
assert!(
!is_housekeeping("SET statement_timeout = 0; SELECT 1"),
"a batch is only housekeeping when every statement in it is"
);
}
#[test]
fn masked_binds_are_excluded_from_comparison() {
assert!(binds_match(
&[BindValue::Masked],
&[Some(b"anything".to_vec())]
));
assert!(binds_match(&[BindValue::Null], &[None]));
assert!(binds_match(
&[BindValue::Value(vec![1, 2])],
&[Some(vec![1, 2])]
));
assert!(!binds_match(
&[BindValue::Value(vec![1, 2])],
&[Some(vec![3, 4])]
));
assert!(!binds_match(&[BindValue::Null], &[Some(vec![1])]));
assert!(
!binds_match(&[BindValue::Masked], &[]),
"a different arity is still a mismatch"
);
}
#[test]
fn the_tape_is_consumed_in_order_and_then_exhausted() {
let tape = ConnectionTape {
id: 3,
role: crate::capsule::schema::TAPE_ROLE_PRIMARY.to_owned(),
prologue: Vec::new(),
statements: Vec::new(),
catalog: Vec::new(),
exchanges: vec![exchange("SELECT 1", Vec::new())],
};
let log = Arc::new(DivergenceLog::new());
let server = StubServer::new(tape, Arc::clone(&log));
assert!(matches!(
server.resolve_execute("SELECT 1", &[]),
Resolution::Recorded(_)
));
match server.resolve_execute("SELECT 1", &[]) {
Resolution::Diverged(divergence) => {
assert_eq!(divergence.kind, DivergenceKind::TapeExhausted);
assert_eq!(divergence.connection, 3);
}
Resolution::Recorded(_) => panic!("the tape held only one exchange"),
}
}
#[test]
fn serving_an_exchange_advances_the_shared_consumption_cursor() {
let tape = ConnectionTape {
id: 5,
role: crate::capsule::schema::TAPE_ROLE_PRIMARY.to_owned(),
prologue: Vec::new(),
statements: Vec::new(),
catalog: Vec::new(),
exchanges: vec![
exchange("SELECT 1", Vec::new()),
exchange("SELECT 2", vec![]),
],
};
let log = Arc::new(DivergenceLog::new());
let server = StubServer::new(tape, Arc::clone(&log));
assert_eq!(log.unconsumed().len(), 1);
let _ = server.resolve_execute("SELECT 1", &[]);
let outstanding = log.unconsumed();
assert_eq!(
outstanding.first().map(|entry| entry.exchange_index),
Some(1),
"one exchange served must leave the cursor on the second: {outstanding:?}"
);
let _ = server.resolve_execute("SELECT 2", &[]);
assert!(
log.unconsumed().is_empty(),
"a fully replayed tape must leave nothing outstanding"
);
}
#[test]
fn the_ordered_cursor_is_consulted_before_the_keyed_buckets() {
let tape = ConnectionTape {
id: 11,
role: crate::capsule::schema::TAPE_ROLE_PRIMARY.to_owned(),
prologue: vec![exchange("BEGIN", Vec::new())],
statements: Vec::new(),
catalog: Vec::new(),
exchanges: vec![
exchange("BEGIN", Vec::new()),
exchange("SELECT 1", Vec::new()),
],
};
let log = Arc::new(DivergenceLog::new());
let server = StubServer::new(tape, Arc::clone(&log));
assert!(matches!(
server.resolve_execute("BEGIN", &[]),
Resolution::Recorded(_)
));
assert_eq!(
server.progress.consumed(),
1,
"answering the exchange the tape expects must consume it, even when the same \
SQL is also in the prologue"
);
assert!(matches!(
server.resolve_execute("SELECT 1", &[]),
Resolution::Recorded(_)
));
assert!(
log.unconsumed().is_empty(),
"the whole tape must be consumable: {:?}",
log.unconsumed()
);
assert!(
log.is_empty(),
"and nothing may diverge: {:?}",
log.entries()
);
assert!(matches!(
server.resolve_execute("BEGIN", &[]),
Resolution::Recorded(_)
));
}
#[test]
fn a_bind_for_an_unknown_statement_diverges() {
let log = Arc::new(DivergenceLog::new());
let mut server = StubServer::new(ConnectionTape::default(), Arc::clone(&log));
let reply = server.extended_batch(&[
FrontendMessage::Bind {
portal: String::new(),
statement: "s7".to_owned(),
params: Vec::new(),
},
FrontendMessage::Execute,
FrontendMessage::Sync,
]);
assert_eq!(
log.entries().first().map(|entry| entry.kind),
Some(DivergenceKind::UnknownStatement),
"an unparsed statement name must be reported, got {:?}",
log.entries()
);
assert!(
reply.starts_with(b"E"),
"the client must get an ErrorResponse rather than a fabricated CommandComplete"
);
}
#[test]
fn an_empty_simple_query_gets_an_empty_query_response() {
let server = StubServer::new(ConnectionTape::default(), Arc::new(DivergenceLog::new()));
let reply = server.simple_query(" ");
assert_eq!(
reply.first(),
Some(&b'I'),
"an empty query is answered with EmptyQueryResponse, got {reply:?}"
);
assert!(!is_housekeeping(""), "an empty batch is not housekeeping");
assert!(
!is_housekeeping("SET statement_timeout = 0; SELECT 1"),
"a batch is only housekeeping when every statement in it is"
);
}
#[tokio::test]
async fn configured_roles_survive_a_capsule_with_no_tape() {
let mut capsule = crate::capsule::schema::test_support::capsule(
crate::capsule::schema::test_support::request("GET", "/boom"),
crate::capsule::schema::CapsuleOutcome::Status {
code: 500,
message: "boom".to_owned(),
problem_type: None,
},
);
assert!(capsule.db.is_none(), "the request issued no wire traffic");
capsule.db_roles = vec![
crate::capsule::schema::TAPE_ROLE_PRIMARY.to_owned(),
crate::capsule::schema::TAPE_ROLE_REPLICA.to_owned(),
];
let replica =
replica_pool_from_capsule(&capsule, Arc::new(DivergenceLog::new())).expect("builds");
assert!(
replica.is_some(),
"a configured replica must exist during replay even with nothing recorded on it"
);
capsule.db_roles.clear();
let none =
replica_pool_from_capsule(&capsule, Arc::new(DivergenceLog::new())).expect("builds");
assert!(none.is_none());
}
#[tokio::test]
async fn a_replay_pool_never_blocks_on_itself() {
let mut capsule = crate::capsule::schema::test_support::capsule(
crate::capsule::schema::test_support::request("GET", "/boom"),
crate::capsule::schema::CapsuleOutcome::Status {
code: 500,
message: "boom".to_owned(),
problem_type: None,
},
);
capsule.db = Some(crate::capsule::schema::CapsuleDb {
connections: vec![ConnectionTape {
id: 1,
role: crate::capsule::schema::TAPE_ROLE_PRIMARY.to_owned(),
..ConnectionTape::default()
}],
});
let pool = pool_from_capsule(&capsule, Arc::new(DivergenceLog::new()))
.expect("the replay pool builds");
assert_eq!(
pool.status().max_size,
2,
"one spare slot beyond the recording keeps a two-connection handler moving"
);
let (first, second) = tokio::time::timeout(
std::time::Duration::from_secs(20),
futures::future::join(pool.get(), pool.get()),
)
.await
.expect("two concurrent checkouts must not deadlock the replay pool");
assert!(
first.is_ok() && second.is_ok(),
"both checkouts must resolve"
);
}
#[test]
fn an_unrecorded_statement_names_itself() {
let tape = ConnectionTape {
id: 1,
role: crate::capsule::schema::TAPE_ROLE_PRIMARY.to_owned(),
prologue: Vec::new(),
statements: Vec::new(),
catalog: Vec::new(),
exchanges: vec![exchange("SELECT 1", Vec::new())],
};
let server = StubServer::new(tape, Arc::new(DivergenceLog::new()));
match server.resolve_execute("SELECT * FROM gadgets", &[]) {
Resolution::Diverged(divergence) => {
assert_eq!(divergence.kind, DivergenceKind::UnrecordedQuery);
assert!(divergence.detail.contains("gadgets"));
assert_eq!(divergence.expected_sql.as_deref(), Some("SELECT 1"));
}
Resolution::Recorded(_) => panic!("nothing recorded should have matched"),
}
}
#[test]
fn a_catalog_probe_diverges_with_a_type_hint() {
let server = StubServer::new(ConnectionTape::default(), Arc::new(DivergenceLog::new()));
let probe = "SELECT enumlabel\nFROM pg_catalog.pg_enum\nWHERE enumtypid = $1\nORDER BY enumsortorder\n";
match server.resolve_execute(probe, &[]) {
Resolution::Diverged(divergence) => {
assert!(
divergence.detail.contains("catalog probe"),
"expected the type-info hint, got {:?}",
divergence.detail
);
}
Resolution::Recorded(_) => panic!("nothing recorded should have matched"),
}
}
}