use std::time::{Duration, Instant};
use sqlx::{PgConnection, PgExecutor, PgPool};
use systemprompt_traits::RepositoryError;
use tracing::warn;
use crate::services::BoxFuture;
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub struct DrainReport {
pub keys: u64,
pub batches: u32,
pub remaining: i64,
pub elapsed: Duration,
pub requeued: u64,
}
pub type RefreshFuture<'c> = BoxFuture<'c, Result<u64, RepositoryError>>;
pub async fn queue<'e, E>(
executor: E,
projection: &str,
keys: &[String],
) -> Result<i64, RepositoryError>
where
E: PgExecutor<'e>,
{
let queued: i64 = sqlx::query_scalar("SELECT queue_projection_backlog($1, $2)")
.bind(projection)
.bind(keys)
.fetch_one(executor)
.await?;
Ok(queued)
}
pub async fn depth<'e, E>(executor: E, projection: &str) -> Result<i64, RepositoryError>
where
E: PgExecutor<'e>,
{
let depth: i64 = sqlx::query_scalar("SELECT projection_backlog_depth($1)")
.bind(projection)
.fetch_one(executor)
.await?;
Ok(depth)
}
pub async fn drain<F>(
pool: &PgPool,
projection: &str,
batch: i32,
budget: Duration,
refresh: F,
) -> Result<DrainReport, RepositoryError>
where
F: for<'c> Fn(&'c mut PgConnection, Vec<String>) -> RefreshFuture<'c> + Send + Sync,
{
let started = Instant::now();
let mut report = DrainReport::default();
while started.elapsed() < budget {
let mut tx = pool.begin().await?;
let keys: Vec<String> = sqlx::query_scalar("SELECT take_projection_backlog($1, $2)")
.bind(projection)
.bind(batch)
.fetch_one(&mut *tx)
.await?;
if keys.is_empty() {
tx.rollback().await?;
break;
}
let taken = u64::try_from(keys.len()).unwrap_or(u64::MAX);
match refresh(&mut tx, keys.clone()).await {
Ok(_) => {
tx.commit().await?;
report.keys = report.keys.saturating_add(taken);
report.batches = report.batches.saturating_add(1);
},
Err(error) => {
tx.rollback().await?;
let requeued: i64 = sqlx::query_scalar("SELECT requeue_projection_backlog($1, $2)")
.bind(projection)
.bind(&keys)
.fetch_one(pool)
.await?;
report.requeued = u64::try_from(requeued).unwrap_or(0);
warn!(
projection,
keys = keys.len(),
error = %error,
"projection refresh failed; batch re-queued one attempt dearer"
);
break;
},
}
}
report.remaining = depth(pool, projection).await?;
report.elapsed = started.elapsed();
Ok(report)
}
pub async fn drain_with_sql(
pool: &PgPool,
projection: &str,
batch: i32,
budget: Duration,
projector: &str,
) -> Result<DrainReport, RepositoryError> {
let statement = projector_statement(projector)?;
let statement = statement.as_str();
drain(pool, projection, batch, budget, move |conn, keys| {
let statement = statement.to_owned();
Box::pin(async move {
let written: i64 = sqlx::query_scalar(sqlx::AssertSqlSafe(statement.as_str()))
.bind(&keys)
.fetch_one(conn)
.await?;
Ok(u64::try_from(written).unwrap_or(0))
})
})
.await
}
fn projector_statement(projector: &str) -> Result<String, RepositoryError> {
let mut chars = projector.chars();
let leads = chars
.next()
.is_some_and(|c| c.is_ascii_lowercase() || c == '_');
let rest = chars.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '_');
if !(leads && rest) {
return Err(RepositoryError::InvalidArgument {
field: "projector",
reason: format!("{projector:?} is not a bare lower-case identifier"),
});
}
Ok(format!("SELECT {projector}($1)"))
}