use std::collections::VecDeque;
use std::sync::{Arc, Mutex, PoisonError};
use sqlx::postgres::PgRow;
use sqlx::{AssertSqlSafe, PgPool};
use crate::item_components::postgres_keyset::{
self, KeysetColumn, KeysetPosition, PagingKeysetSchema, PostgresComponentConfigError,
PostgresRow, classify_pg_error, extract_keyset, keyset_predicate_sql, order_by_sql,
validate_key_columns,
};
use crate::{
CodecId, CodecVersion, ComponentStateEnvelope, ComponentStreamIdentity, DefaultComponentCodec,
FailureCategory, PostgresConfig, ReadContext, ReadOutcome, ReaderError,
RestartabilityDeclaration, StateLimits, StreamCloseContext, StreamCloseError,
StreamCloseOutcome, StreamOpenContext, StreamOpenError, StreamOpenOutcome, StreamStateContract,
StreamUpdateContext, StreamUpdateError,
};
pub const DEFAULT_PAGE_SIZE: usize = 500;
#[derive(Clone, Copy, Debug)]
pub struct PostgresPagingFormat {
page_size: usize,
}
impl PostgresPagingFormat {
#[must_use]
pub const fn new() -> Self {
Self {
page_size: DEFAULT_PAGE_SIZE,
}
}
#[must_use]
pub const fn with_page_size(mut self, page_size: usize) -> Self {
self.page_size = page_size;
self
}
}
impl Default for PostgresPagingFormat {
fn default() -> Self {
Self::new()
}
}
fn paging_keyset_codec() -> DefaultComponentCodec<PagingKeysetSchema> {
#[allow(
clippy::unwrap_used,
reason = "fixed literal identities cannot fail validation"
)]
DefaultComponentCodec::new(
PagingKeysetSchema,
CodecId::new("oxide-batch.postgres-paging-reader-position-codec").unwrap(),
CodecVersion::new(1).unwrap(),
RestartabilityDeclaration::Restartable,
)
}
fn page_query_sql(base_query: &str, key_columns: &[KeysetColumn], has_restored: bool) -> String {
let order_by = order_by_sql(key_columns);
if has_restored {
let predicate = keyset_predicate_sql(key_columns, 1);
let limit_param = key_columns.len() + 1;
format!(
"SELECT * FROM ({base_query}) AS ob_page_source WHERE {predicate} \
ORDER BY {order_by} LIMIT ${limit_param}"
)
} else {
format!("SELECT * FROM ({base_query}) AS ob_page_source ORDER BY {order_by} LIMIT $1")
}
}
pub struct PostgresPagingReader<I> {
config: PostgresConfig,
pool: Option<PgPool>,
base_query: String,
key_columns: Vec<KeysetColumn>,
page_size: usize,
#[allow(clippy::type_complexity, reason = "one caller-supplied row mapper")]
map_row: Arc<dyn Fn(&PostgresRow<'_>) -> Result<I, ReaderError> + Send + Sync>,
position: Arc<Mutex<KeysetPosition>>,
buffered: VecDeque<PgRow>,
exhausted: bool,
}
impl<I> PostgresPagingReader<I> {
async fn pool(&mut self) -> Result<&PgPool, ReaderError> {
if self.pool.is_none() {
let pool = self.config.connect_pool().await.map_err(|_| {
ReaderError::with_category(FailureCategory::TransientInfrastructure)
})?;
self.pool = Some(pool);
}
#[allow(clippy::unwrap_used, reason = "just populated above when it was None")]
Ok(self.pool.as_ref().unwrap())
}
async fn fetch_next_page(&mut self) -> Result<(), ReaderError> {
if self.exhausted {
return Ok(());
}
let restored = self
.position
.lock()
.unwrap_or_else(PoisonError::into_inner)
.clone();
let sql = page_query_sql(&self.base_query, &self.key_columns, !restored.is_empty());
let mut query = sqlx::query(AssertSqlSafe(sql));
if !restored.is_empty() {
query = postgres_keyset::bind_keyset(query, restored.values());
}
let page_size = i64::try_from(self.page_size)
.map_err(|_| ReaderError::with_category(FailureCategory::Invariant))?;
query = query.bind(page_size);
let pool = self.pool().await?;
let rows = query
.fetch_all(pool)
.await
.map_err(|error| ReaderError::with_category(classify_pg_error(&error)))?;
if rows.len() < self.page_size {
self.exhausted = true;
}
self.buffered.extend(rows);
Ok(())
}
}
impl<I> crate::ItemReader<I> for PostgresPagingReader<I>
where
I: Send + 'static,
{
async fn read(&mut self, _context: ReadContext<'_>) -> Result<ReadOutcome<I>, ReaderError> {
if self.buffered.is_empty() && !self.exhausted {
self.fetch_next_page().await?;
}
let Some(row) = self.buffered.front() else {
return Ok(ReadOutcome::EndOfInput);
};
let key = extract_keyset(row, &self.key_columns)?;
let item = (self.map_row)(&PostgresRow::new(row))?;
self.buffered.pop_front();
*self.position.lock().unwrap_or_else(PoisonError::into_inner) = key;
Ok(ReadOutcome::Item(item))
}
}
pub struct PostgresPagingReaderStream {
position: Arc<Mutex<KeysetPosition>>,
namespace: ComponentStreamIdentity,
}
impl crate::ItemStream for PostgresPagingReaderStream {
async fn open(
&self,
context: StreamOpenContext<'_>,
) -> Result<StreamOpenOutcome, StreamOpenError> {
let codec = paging_keyset_codec();
if let Some(envelope) = context.inherited_state() {
let restored = envelope
.decode::<KeysetPosition>(&codec)
.map_err(|_| StreamOpenError::new())?;
*self.position.lock().unwrap_or_else(PoisonError::into_inner) = restored;
Ok(StreamOpenOutcome::Restored)
} else {
*self.position.lock().unwrap_or_else(PoisonError::into_inner) = KeysetPosition::none();
Ok(StreamOpenOutcome::Initial)
}
}
async fn update(
&self,
_context: StreamUpdateContext<'_>,
) -> Result<ComponentStateEnvelope, StreamUpdateError> {
let codec = paging_keyset_codec();
let current = self
.position
.lock()
.unwrap_or_else(PoisonError::into_inner)
.clone();
ComponentStateEnvelope::encode(
self.namespace.clone(),
¤t,
&codec,
StateLimits::default(),
)
.map_err(|_| StreamUpdateError::new())
}
async fn close(
&self,
_context: StreamCloseContext<'_>,
) -> Result<StreamCloseOutcome, StreamCloseError> {
Ok(StreamCloseOutcome::Closed)
}
}
pub fn postgres_paging_reader<I>(
config: PostgresConfig,
base_query: impl Into<String>,
key_columns: Vec<KeysetColumn>,
format: PostgresPagingFormat,
map_row: impl Fn(&PostgresRow<'_>) -> Result<I, ReaderError> + Send + Sync + 'static,
identity: ComponentStreamIdentity,
) -> Result<
(
PostgresPagingReader<I>,
PostgresPagingReaderStream,
StreamStateContract,
),
PostgresComponentConfigError,
>
where
I: Send + 'static,
{
validate_key_columns(&key_columns)?;
if format.page_size == 0 {
return Err(PostgresComponentConfigError::InvalidFetchSize);
}
let position = Arc::new(Mutex::new(KeysetPosition::none()));
let reader = PostgresPagingReader {
config,
pool: None,
base_query: base_query.into(),
key_columns,
page_size: format.page_size,
map_row: Arc::new(map_row),
position: Arc::clone(&position),
buffered: VecDeque::new(),
exhausted: false,
};
let stream = PostgresPagingReaderStream {
position,
namespace: identity,
};
let contract = StreamStateContract::new(paging_keyset_codec());
Ok((reader, stream, contract))
}
#[cfg(test)]
mod tests {
#![allow(clippy::unwrap_used, clippy::expect_used)]
use super::*;
#[test]
fn page_query_sql_omits_the_predicate_on_initial_execution() {
let columns = [KeysetColumn::i64("id")];
let sql = page_query_sql("SELECT id FROM t", &columns, false);
assert!(!sql.contains("WHERE"));
assert!(sql.contains("LIMIT $1"));
}
#[test]
fn page_query_sql_places_limit_after_the_composite_predicate() {
let columns = [KeysetColumn::text("email"), KeysetColumn::i64("id")];
let sql = page_query_sql("SELECT email, id FROM t", &columns, true);
assert!(sql.contains("WHERE (email, id) > ($1, $2)"));
assert!(sql.contains("LIMIT $3"));
}
}