Introduce remora-durable with pool/migrations, a RemoraApiMutation-shaped ledger, claim-fenced outbox projection, CAS helpers, and keyset cursors, plus a kind Job acceptance suite against the hermetic fixture.
90 lines
2.4 KiB
Rust
90 lines
2.4 KiB
Rust
use sqlx::{AssertSqlSafe, PgPool};
|
|
|
|
use crate::error::DurableError;
|
|
use crate::tx::{self, PgTx};
|
|
|
|
/// Optimistic compare-and-swap on a caller-owned table.
|
|
///
|
|
/// The table must expose `revision BIGINT NOT NULL` and a primary key column
|
|
/// bound as `$1`. Feature packages own the surrounding schema; this helper only
|
|
/// serializes the revision bump. Identifiers are restricted to lowercase SQL
|
|
/// identifiers before interpolation.
|
|
pub async fn cas_set_text(
|
|
tx: &mut PgTx<'_>,
|
|
table: &str,
|
|
id_column: &str,
|
|
id: &str,
|
|
value_column: &str,
|
|
value: &str,
|
|
expected_revision: i64,
|
|
) -> Result<i64, DurableError> {
|
|
validate_ident(table)?;
|
|
validate_ident(id_column)?;
|
|
validate_ident(value_column)?;
|
|
|
|
let sql = format!(
|
|
"UPDATE {table}
|
|
SET {value_column} = $2,
|
|
revision = revision + 1
|
|
WHERE {id_column} = $1
|
|
AND revision = $3
|
|
RETURNING revision"
|
|
);
|
|
let next: Option<(i64,)> = sqlx::query_as(AssertSqlSafe(sql))
|
|
.bind(id)
|
|
.bind(value)
|
|
.bind(expected_revision)
|
|
.fetch_optional(tx.as_mut())
|
|
.await?;
|
|
next.map(|(revision,)| revision)
|
|
.ok_or(DurableError::CasConflict)
|
|
}
|
|
|
|
pub async fn cas_set_text_pool(
|
|
pool: &PgPool,
|
|
table: &str,
|
|
id_column: &str,
|
|
id: &str,
|
|
value_column: &str,
|
|
value: &str,
|
|
expected_revision: i64,
|
|
) -> Result<i64, DurableError> {
|
|
let table = table.to_owned();
|
|
let id_column = id_column.to_owned();
|
|
let id = id.to_owned();
|
|
let value_column = value_column.to_owned();
|
|
let value = value.to_owned();
|
|
tx::with_transaction(pool, move |tx| {
|
|
Box::pin(async move {
|
|
cas_set_text(
|
|
tx,
|
|
&table,
|
|
&id_column,
|
|
&id,
|
|
&value_column,
|
|
&value,
|
|
expected_revision,
|
|
)
|
|
.await
|
|
})
|
|
})
|
|
.await
|
|
}
|
|
|
|
fn validate_ident(value: &str) -> Result<(), DurableError> {
|
|
if value.is_empty()
|
|
|| value.len() > 63
|
|
|| !value.chars().all(|character| {
|
|
character.is_ascii_lowercase() || character.is_ascii_digit() || character == '_'
|
|
})
|
|
|| value
|
|
.chars()
|
|
.next()
|
|
.is_none_or(|character| !(character.is_ascii_lowercase() || character == '_'))
|
|
{
|
|
return Err(DurableError::Config(format!(
|
|
"invalid SQL identifier '{value}'"
|
|
)));
|
|
}
|
|
Ok(())
|
|
}
|