use std::time::Duration;
use reliar_core::Serializer;
use reliar_outbox::{CompletedRecord, FailedRecord, FailureOutcome, RecordRef, WorkerId};
use super::error::PostgresOutboxError;
use super::PostgresOutboxStore;
pub(super) async fn complete<Ser: Serializer + Send + Sync + 'static>(
store: &PostgresOutboxStore<Ser>,
worker: &WorkerId,
items: &[CompletedRecord],
) -> Result<u64, PostgresOutboxError> {
if items.is_empty() {
return Ok(0);
}
let ids: Vec<uuid::Uuid> = items.iter().map(|i| i.record.id.as_uuid()).collect();
let affected = if store.settings.statement_timeout.is_zero() {
complete_rows(&store.pool, &ids, worker.as_str())
.await
.map_err(|e| store.map_err(e))?
} else {
let mut tx = store.pool.begin().await.map_err(|e| store.map_err(e))?;
store.set_local_timeout(&mut tx).await?;
let affected = complete_rows(&mut *tx, &ids, worker.as_str())
.await
.map_err(|e| store.map_err(e))?;
tx.commit().await.map_err(|e| store.map_err(e))?;
affected
};
log_shortfall("complete", items.len(), affected);
Ok(affected)
}
pub(super) async fn fail<Ser: Serializer + Send + Sync + 'static>(
store: &PostgresOutboxStore<Ser>,
worker: &WorkerId,
items: &[FailedRecord],
) -> Result<u64, PostgresOutboxError> {
if items.is_empty() {
return Ok(0);
}
let mut retry_ids = Vec::new();
let mut retry_errors = Vec::new();
let mut retry_delays = Vec::new();
let mut dead_ids = Vec::new();
let mut dead_errors = Vec::new();
let mut dead_reasons = Vec::new();
for item in items {
match item.outcome {
FailureOutcome::Retry { delay } => {
retry_ids.push(item.record.id.as_uuid());
retry_errors.push(item.error.clone());
retry_delays.push(i64::try_from(delay.as_millis()).unwrap_or(i64::MAX));
}
FailureOutcome::Dead { reason } => {
dead_ids.push(item.record.id.as_uuid());
dead_errors.push(item.error.clone());
dead_reasons.push(crate::records::encode_dead_reason(reason));
}
_ => tracing::error!(
id = %item.record.id,
"unrecognised FailureOutcome variant; row left as-is"
),
}
}
let affected = if store.settings.statement_timeout.is_zero() {
let mut affected = 0u64;
if !retry_ids.is_empty() {
affected += fail_retry_rows(
&store.pool,
&retry_ids,
&retry_errors,
&retry_delays,
worker.as_str(),
)
.await
.map_err(|e| store.map_err(e))?;
}
if !dead_ids.is_empty() {
affected += fail_dead_rows(
&store.pool,
&dead_ids,
&dead_errors,
&dead_reasons,
worker.as_str(),
)
.await
.map_err(|e| store.map_err(e))?;
}
affected
} else {
let mut tx = store.pool.begin().await.map_err(|e| store.map_err(e))?;
store.set_local_timeout(&mut tx).await?;
let mut affected = 0u64;
if !retry_ids.is_empty() {
affected += fail_retry_rows(
&mut *tx,
&retry_ids,
&retry_errors,
&retry_delays,
worker.as_str(),
)
.await
.map_err(|e| store.map_err(e))?;
}
if !dead_ids.is_empty() {
affected += fail_dead_rows(
&mut *tx,
&dead_ids,
&dead_errors,
&dead_reasons,
worker.as_str(),
)
.await
.map_err(|e| store.map_err(e))?;
}
tx.commit().await.map_err(|e| store.map_err(e))?;
affected
};
log_shortfall("fail", items.len(), affected);
Ok(affected)
}
pub(super) async fn release<Ser: Serializer + Send + Sync + 'static>(
store: &PostgresOutboxStore<Ser>,
worker: &WorkerId,
items: &[RecordRef],
) -> Result<u64, PostgresOutboxError> {
if items.is_empty() {
return Ok(0);
}
let ids: Vec<uuid::Uuid> = items.iter().map(|i| i.id.as_uuid()).collect();
let affected = if store.settings.statement_timeout.is_zero() {
release_rows(&store.pool, &ids, worker.as_str())
.await
.map_err(|e| store.map_err(e))?
} else {
let mut tx = store.pool.begin().await.map_err(|e| store.map_err(e))?;
store.set_local_timeout(&mut tx).await?;
let affected = release_rows(&mut *tx, &ids, worker.as_str())
.await
.map_err(|e| store.map_err(e))?;
tx.commit().await.map_err(|e| store.map_err(e))?;
affected
};
log_shortfall("release", items.len(), affected);
Ok(affected)
}
pub(super) async fn extend_lease<Ser: Serializer + Send + Sync + 'static>(
store: &PostgresOutboxStore<Ser>,
worker: &WorkerId,
items: &[RecordRef],
lease: Duration,
) -> Result<u64, PostgresOutboxError> {
if items.is_empty() {
return Ok(0);
}
let ids: Vec<uuid::Uuid> = items.iter().map(|i| i.id.as_uuid()).collect();
let lease_ms = i64::try_from(lease.as_millis()).unwrap_or(i64::MAX);
let affected = if store.settings.statement_timeout.is_zero() {
extend_lease_rows(&store.pool, &ids, lease_ms, worker.as_str())
.await
.map_err(|e| store.map_err(e))?
} else {
let mut tx = store.pool.begin().await.map_err(|e| store.map_err(e))?;
store.set_local_timeout(&mut tx).await?;
let affected = extend_lease_rows(&mut *tx, &ids, lease_ms, worker.as_str())
.await
.map_err(|e| store.map_err(e))?;
tx.commit().await.map_err(|e| store.map_err(e))?;
affected
};
log_shortfall("extend_lease", items.len(), affected);
Ok(affected)
}
async fn complete_rows<'e>(
executor: impl sqlx::PgExecutor<'e>,
ids: &[uuid::Uuid],
worker: &str,
) -> Result<u64, sqlx::Error> {
let result = sqlx::query!(
r#"UPDATE outbox
SET published_at = now(),
attempts = attempts + 1,
locked_by = NULL,
locked_until = NULL,
updated_at = now()
WHERE id = ANY($1) AND locked_by = $2"#,
ids,
worker,
)
.execute(executor)
.await?;
Ok(result.rows_affected())
}
async fn release_rows<'e>(
executor: impl sqlx::PgExecutor<'e>,
ids: &[uuid::Uuid],
worker: &str,
) -> Result<u64, sqlx::Error> {
let result = sqlx::query!(
r#"UPDATE outbox
SET locked_by = NULL,
locked_until = NULL,
available_at = now(),
updated_at = now()
WHERE id = ANY($1) AND locked_by = $2"#,
ids,
worker,
)
.execute(executor)
.await?;
Ok(result.rows_affected())
}
async fn extend_lease_rows<'e>(
executor: impl sqlx::PgExecutor<'e>,
ids: &[uuid::Uuid],
lease_ms: i64,
worker: &str,
) -> Result<u64, sqlx::Error> {
let result = sqlx::query!(
r#"UPDATE outbox
SET locked_until = now() + ($2::bigint * interval '1 millisecond'),
available_at = now() + ($2::bigint * interval '1 millisecond'),
updated_at = now()
WHERE id = ANY($1) AND locked_by = $3"#,
ids,
lease_ms,
worker,
)
.execute(executor)
.await?;
Ok(result.rows_affected())
}
async fn fail_retry_rows<'e>(
executor: impl sqlx::PgExecutor<'e>,
ids: &[uuid::Uuid],
errors: &[String],
delays_ms: &[i64],
worker: &str,
) -> Result<u64, sqlx::Error> {
let result = sqlx::query!(
r#"UPDATE outbox o
SET attempts = o.attempts + 1,
last_error = f.err,
locked_by = NULL,
locked_until = NULL,
available_at = now() + (f.delay_ms * interval '1 millisecond'),
updated_at = now()
FROM UNNEST($1::uuid[], $2::text[], $3::bigint[]) AS f(id, err, delay_ms)
WHERE o.id = f.id AND o.locked_by = $4"#,
ids,
errors,
delays_ms,
worker,
)
.execute(executor)
.await?;
Ok(result.rows_affected())
}
async fn fail_dead_rows<'e>(
executor: impl sqlx::PgExecutor<'e>,
ids: &[uuid::Uuid],
errors: &[String],
reasons: &[&str],
worker: &str,
) -> Result<u64, sqlx::Error> {
let result = sqlx::query!(
r#"UPDATE outbox o
SET attempts = o.attempts + 1,
last_error = f.err,
dead_at = now(),
dead_reason = f.reason,
locked_by = NULL,
locked_until = NULL,
updated_at = now()
FROM UNNEST($1::uuid[], $2::text[], $3::text[]) AS f(id, err, reason)
WHERE o.id = f.id AND o.locked_by = $4"#,
ids,
errors,
reasons as &[&str],
worker,
)
.execute(executor)
.await?;
Ok(result.rows_affected())
}
fn log_shortfall(operation: &'static str, claimed: usize, affected: u64) {
let claimed = claimed as u64;
if affected < claimed {
tracing::debug!(
operation,
claimed,
affected,
"fewer rows affected than claimed"
);
}
}