use std::collections::VecDeque;
use std::sync::{Arc, Mutex, PoisonError};
use sqlx::postgres::PgRow;
use sqlx::{AssertSqlSafe, Postgres};
use crate::item_components::postgres_keyset::{
self, CursorKeysetSchema, KeysetColumn, KeysetPosition, 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_FETCH_SIZE: usize = 500;
const CURSOR_NAME: &str = "oxide_batch_cursor";
#[derive(Clone, Copy, Debug)]
pub struct PostgresCursorFormat {
fetch_size: usize,
}
impl PostgresCursorFormat {
#[must_use]
pub const fn new() -> Self {
Self {
fetch_size: DEFAULT_FETCH_SIZE,
}
}
#[must_use]
pub const fn with_fetch_size(mut self, fetch_size: usize) -> Self {
self.fetch_size = fetch_size;
self
}
}
impl Default for PostgresCursorFormat {
fn default() -> Self {
Self::new()
}
}
fn cursor_keyset_codec() -> DefaultComponentCodec<CursorKeysetSchema> {
#[allow(
clippy::unwrap_used,
reason = "fixed literal identities cannot fail validation"
)]
DefaultComponentCodec::new(
CursorKeysetSchema,
CodecId::new("oxide-batch.postgres-cursor-reader-position-codec").unwrap(),
CodecVersion::new(1).unwrap(),
RestartabilityDeclaration::Restartable,
)
}
fn declare_cursor_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);
format!(
"DECLARE {CURSOR_NAME} NO SCROLL CURSOR WITHOUT HOLD FOR \
SELECT * FROM ({base_query}) AS ob_cursor_source WHERE {predicate} ORDER BY {order_by}"
)
} else {
format!(
"DECLARE {CURSOR_NAME} NO SCROLL CURSOR WITHOUT HOLD FOR \
SELECT * FROM ({base_query}) AS ob_cursor_source ORDER BY {order_by}"
)
}
}
pub struct PostgresCursorReader<I> {
config: PostgresConfig,
base_query: String,
key_columns: Vec<KeysetColumn>,
fetch_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>>,
transaction_slot: Arc<Mutex<Option<sqlx::Transaction<'static, Postgres>>>>,
buffered: VecDeque<PgRow>,
started: bool,
done: bool,
}
impl<I> PostgresCursorReader<I> {
async fn ensure_started(&mut self) -> Result<(), ReaderError> {
if self.started {
return Ok(());
}
let restored = self
.position
.lock()
.unwrap_or_else(PoisonError::into_inner)
.clone();
let pool =
self.config.connect_pool().await.map_err(|_| {
ReaderError::with_category(FailureCategory::TransientInfrastructure)
})?;
let mut transaction = pool
.begin()
.await
.map_err(|error| ReaderError::with_category(classify_pg_error(&error)))?;
let sql = declare_cursor_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());
}
if let Err(error) = query.execute(&mut *transaction).await {
let category = classify_pg_error(&error);
let _ = transaction.rollback().await;
return Err(ReaderError::with_category(category));
}
*self
.transaction_slot
.lock()
.unwrap_or_else(PoisonError::into_inner) = Some(transaction);
self.started = true;
Ok(())
}
async fn fetch_more(&mut self) -> Result<(), ReaderError> {
if self.done {
return Ok(());
}
let Some(mut transaction) = self
.transaction_slot
.lock()
.unwrap_or_else(PoisonError::into_inner)
.take()
else {
return Err(ReaderError::with_category(FailureCategory::Invariant));
};
let sql = format!("FETCH FORWARD {} FROM {CURSOR_NAME}", self.fetch_size);
match sqlx::query(AssertSqlSafe(sql))
.fetch_all(&mut *transaction)
.await
{
Ok(rows) => {
if rows.is_empty() {
self.done = true;
} else {
self.buffered.extend(rows);
}
*self
.transaction_slot
.lock()
.unwrap_or_else(PoisonError::into_inner) = Some(transaction);
Ok(())
}
Err(error) => {
let category = classify_pg_error(&error);
drop(transaction);
self.started = false;
Err(ReaderError::with_category(category))
}
}
}
}
impl<I> crate::ItemReader<I> for PostgresCursorReader<I>
where
I: Send + 'static,
{
async fn read(&mut self, _context: ReadContext<'_>) -> Result<ReadOutcome<I>, ReaderError> {
self.ensure_started().await?;
if self.buffered.is_empty() && !self.done {
self.fetch_more().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 PostgresCursorReaderStream {
position: Arc<Mutex<KeysetPosition>>,
transaction_slot: Arc<Mutex<Option<sqlx::Transaction<'static, Postgres>>>>,
namespace: ComponentStreamIdentity,
}
impl crate::ItemStream for PostgresCursorReaderStream {
async fn open(
&self,
context: StreamOpenContext<'_>,
) -> Result<StreamOpenOutcome, StreamOpenError> {
let codec = cursor_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 = cursor_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> {
let transaction = self
.transaction_slot
.lock()
.unwrap_or_else(PoisonError::into_inner)
.take();
if let Some(transaction) = transaction {
let _ = transaction.rollback().await;
}
Ok(StreamCloseOutcome::Closed)
}
}
pub fn postgres_cursor_reader<I>(
config: PostgresConfig,
base_query: impl Into<String>,
key_columns: Vec<KeysetColumn>,
format: PostgresCursorFormat,
map_row: impl Fn(&PostgresRow<'_>) -> Result<I, ReaderError> + Send + Sync + 'static,
identity: ComponentStreamIdentity,
) -> Result<
(
PostgresCursorReader<I>,
PostgresCursorReaderStream,
StreamStateContract,
),
PostgresComponentConfigError,
>
where
I: Send + 'static,
{
validate_key_columns(&key_columns)?;
if format.fetch_size == 0 {
return Err(PostgresComponentConfigError::InvalidFetchSize);
}
let position = Arc::new(Mutex::new(KeysetPosition::none()));
let transaction_slot = Arc::new(Mutex::new(None));
let reader = PostgresCursorReader {
config,
base_query: base_query.into(),
key_columns,
fetch_size: format.fetch_size,
map_row: Arc::new(map_row),
position: Arc::clone(&position),
transaction_slot: Arc::clone(&transaction_slot),
buffered: VecDeque::new(),
started: false,
done: false,
};
let stream = PostgresCursorReaderStream {
position,
transaction_slot,
namespace: identity,
};
let contract = StreamStateContract::new(cursor_keyset_codec());
Ok((reader, stream, contract))
}
#[cfg(test)]
mod tests {
#![allow(clippy::unwrap_used, clippy::expect_used)]
use super::*;
#[test]
fn declare_cursor_sql_omits_the_predicate_on_initial_execution() {
let columns = [KeysetColumn::i64("id")];
let sql = declare_cursor_sql("SELECT id FROM t", &columns, false);
assert!(!sql.contains("WHERE"));
assert!(sql.contains("ORDER BY id"));
}
#[test]
fn declare_cursor_sql_includes_the_composite_predicate_on_restart() {
let columns = [KeysetColumn::text("email"), KeysetColumn::i64("id")];
let sql = declare_cursor_sql("SELECT email, id FROM t", &columns, true);
assert!(sql.contains("WHERE (email, id) > ($1, $2)"));
assert!(sql.contains("ORDER BY email, id"));
}
}