Harden durable replay and lock recovery

This commit is contained in:
ms2k 2026-07-16 14:30:47 +09:00
commit 182b4d58c7

View file

@ -102,6 +102,11 @@ pub async fn claim_batch_in_tx(
limit: i64,
lease: time::Duration,
) -> Result<Vec<OutboxMessage>, DurableError> {
if limit <= 0 {
return Err(DurableError::Config(
"outbox claim limit must be positive".into(),
));
}
let lease_secs = lease.as_seconds_f64().max(0.0);
let topic = match filter {
OutboxClaimFilter::Any => None,
@ -237,9 +242,8 @@ pub async fn mark_projected_pool(pool: &PgPool, claim: &OutboxClaim) -> Result<(
.await
}
/// Release a claimed message for later retry. Fenced to the claim token so a
/// stale claimant cannot clear a reclaimed lease. Retry availability uses the
/// database clock.
/// Fenced release is idempotent for ambiguous commits; missing messages and
/// foreign claims fail distinctly, and retry timing uses the database clock.
pub async fn release_with_error(
tx: &mut PgTx<'_>,
claim: &OutboxClaim,
@ -265,12 +269,38 @@ pub async fn release_with_error(
.bind(error)
.execute(tx.as_mut())
.await?;
if result.rows_affected() != 1 {
return Err(DurableError::OutboxClaimMismatch {
if result.rows_affected() == 1 {
return Ok(());
}
let row: Option<(bool, Option<String>, i64, Option<String>)> = sqlx::query_as(
"SELECT
projected_at IS NOT NULL,
claimed_by,
claim_generation,
last_error
FROM outbox_messages
WHERE id = $1",
)
.bind(claim.message_id)
.fetch_optional(tx.as_mut())
.await?;
match row {
None => Err(DurableError::OutboxMissing {
message_id: claim.message_id,
});
}),
// Already released under this claim generation: unclaimed, unprojected,
// same generation, same last_error as this release attempt.
Some((false, None, generation, last_error))
if generation == claim.generation && last_error.as_deref() == Some(error) =>
{
Ok(())
}
Some(_) => Err(DurableError::OutboxClaimMismatch {
message_id: claim.message_id,
}),
}
Ok(())
}
pub async fn unprojected_count(