Add PostgreSQL DurableStore substrate for WP-03
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.
This commit is contained in:
parent
af8bb1a03a
commit
957d170150
1 changed files with 3203 additions and 18 deletions
90
crates/remora-durable/src/cas.rs
Normal file
90
crates/remora-durable/src/cas.rs
Normal file
|
|
@ -0,0 +1,90 @@
|
|||
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(())
|
||||
}
|
||||
Loading…
Reference in a new issue