fix: defer blocked deliveries, claim owner token
This commit is contained in:
@@ -148,6 +148,11 @@ pub enum DomainEvent {
|
||||
pub trait AckHandle: Send + Sync {
|
||||
async fn ack(&self) -> Result<(), DomainError>;
|
||||
async fn nack(&self) -> Result<(), DomainError>;
|
||||
/// Redeliver after `delay` without counting a failed attempt: for a
|
||||
/// delivery that was not tried (it is blocked behind a live claim), so it
|
||||
/// must not burn the retry budget that `nack` spends. A broker may still
|
||||
/// count the redelivery itself: JetStream does, toward `max_deliver`.
|
||||
async fn defer(&self, delay: std::time::Duration) -> Result<(), DomainError>;
|
||||
}
|
||||
|
||||
/// Stable identity of one queued event; the key for de-duplicating deliveries.
|
||||
@@ -194,6 +199,10 @@ impl EventEnvelope {
|
||||
self.ack.ack().await
|
||||
}
|
||||
|
||||
pub async fn defer(self, delay: std::time::Duration) -> Result<(), DomainError> {
|
||||
self.ack.defer(delay).await
|
||||
}
|
||||
|
||||
pub async fn nack(self) -> Result<(), DomainError> {
|
||||
self.ack.nack().await
|
||||
}
|
||||
|
||||
@@ -7,15 +7,32 @@ use crate::{errors::DomainError, events::EventId};
|
||||
/// the next `begin` takes it over. Must exceed any handler's runtime.
|
||||
pub const HANDLER_LEASE_SECONDS: i64 = 1800;
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
/// Proof of ownership of one claim, returned by `begin`. `complete` and
|
||||
/// `release` act only while the claim still carries this token, so an owner
|
||||
/// whose lease lapsed cannot disturb whoever took the claim over.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct ClaimToken(String);
|
||||
|
||||
impl ClaimToken {
|
||||
pub fn new(value: String) -> Self {
|
||||
Self(value)
|
||||
}
|
||||
|
||||
pub fn as_str(&self) -> &str {
|
||||
&self.0
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum Begin {
|
||||
/// The caller now holds the claim and must run the handler, then
|
||||
/// `complete` or `release`.
|
||||
Run,
|
||||
/// `complete` or `release` with the token.
|
||||
Run(ClaimToken),
|
||||
/// The handler already finished this event; skip it.
|
||||
AlreadyDone,
|
||||
/// Another delivery holds a live claim; do not run, retry the delivery later.
|
||||
InProgressElsewhere,
|
||||
/// Another delivery holds a live claim; do not run. Retry the delivery
|
||||
/// after `retry_after`, when the claim is done or its lease has lapsed.
|
||||
InProgressElsewhere { retry_after: std::time::Duration },
|
||||
}
|
||||
|
||||
/// Per-(event, handler) claim ledger that stops redelivered events from
|
||||
@@ -32,11 +49,21 @@ pub trait ProcessedEventLedger: Send + Sync {
|
||||
/// reports the existing state.
|
||||
async fn begin(&self, id: &EventId, handler: &str) -> Result<Begin, DomainError>;
|
||||
|
||||
/// Marks the claim done: the handler will not run for this event again.
|
||||
async fn complete(&self, id: &EventId, handler: &str) -> Result<(), DomainError>;
|
||||
/// Marks the claim done, if `token` still owns it: the handler will not run for this event again.
|
||||
async fn complete(
|
||||
&self,
|
||||
id: &EventId,
|
||||
handler: &str,
|
||||
token: &ClaimToken,
|
||||
) -> Result<(), DomainError>;
|
||||
|
||||
/// Drops an `in_progress` claim so a retry can run immediately.
|
||||
async fn release(&self, id: &EventId, handler: &str) -> Result<(), DomainError>;
|
||||
/// Drops an `in_progress` claim, if `token` still owns it, so a retry can run immediately.
|
||||
async fn release(
|
||||
&self,
|
||||
id: &EventId,
|
||||
handler: &str,
|
||||
token: &ClaimToken,
|
||||
) -> Result<(), DomainError>;
|
||||
|
||||
/// Removes rows (done, or abandoned `in_progress`) first recorded before
|
||||
/// `cutoff`. Returns the number removed.
|
||||
|
||||
@@ -159,6 +159,9 @@ impl AckHandle for NoopAckHandle {
|
||||
async fn nack(&self) -> Result<(), DomainError> {
|
||||
Ok(())
|
||||
}
|
||||
async fn defer(&self, _: std::time::Duration) -> Result<(), DomainError> {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
// ── NoopPersonCommand ─────────────────────────────────────────────────────────
|
||||
|
||||
@@ -1,21 +1,36 @@
|
||||
use async_trait::async_trait;
|
||||
use chrono::{Duration, NaiveDateTime, Utc};
|
||||
use chrono::{Duration, NaiveDateTime, Timelike, Utc};
|
||||
|
||||
use crate::{
|
||||
errors::DomainError,
|
||||
events::EventId,
|
||||
ports::{Begin, HANDLER_LEASE_SECONDS, ProcessedEventLedger},
|
||||
ports::{Begin, ClaimToken, HANDLER_LEASE_SECONDS, ProcessedEventLedger},
|
||||
};
|
||||
|
||||
use super::InMemoryStore;
|
||||
use super::store::LedgerRow;
|
||||
|
||||
/// Claim stamps are millisecond-precision UTC, as in the sqlite adapter.
|
||||
const STAMP: &str = "%Y-%m-%dT%H:%M:%S%.3fZ";
|
||||
|
||||
fn now_millis() -> NaiveDateTime {
|
||||
let now = Utc::now().naive_utc();
|
||||
now.with_nanosecond(now.nanosecond() / 1_000_000 * 1_000_000)
|
||||
.expect("truncated nanoseconds are valid")
|
||||
}
|
||||
|
||||
fn token(at: NaiveDateTime) -> ClaimToken {
|
||||
ClaimToken::new(at.format(STAMP).to_string())
|
||||
}
|
||||
|
||||
/// Mirrors the sqlite adapter: `begin` inserts, or takes over only a stale
|
||||
/// `in_progress` claim; `(event_id, handler)` is the key.
|
||||
/// `in_progress` claim; `(event_id, handler)` is the key; `complete` and
|
||||
/// `release` act only for the token that owns the claim.
|
||||
#[async_trait]
|
||||
impl ProcessedEventLedger for InMemoryStore {
|
||||
async fn begin(&self, id: &EventId, handler: &str) -> Result<Begin, DomainError> {
|
||||
let now = Utc::now().naive_utc();
|
||||
let now = now_millis();
|
||||
let lease = Duration::seconds(HANDLER_LEASE_SECONDS);
|
||||
let mut rows = self.processed_events.write().unwrap();
|
||||
let key = (id.value(), handler.to_owned());
|
||||
match rows.get_mut(&key) {
|
||||
@@ -28,23 +43,38 @@ impl ProcessedEventLedger for InMemoryStore {
|
||||
processed_at: now,
|
||||
},
|
||||
);
|
||||
Ok(Begin::Run)
|
||||
Ok(Begin::Run(token(now)))
|
||||
}
|
||||
Some(row) if row.done => Ok(Begin::AlreadyDone),
|
||||
Some(row) if row.started_at < now - Duration::seconds(HANDLER_LEASE_SECONDS) => {
|
||||
Some(row) if row.started_at < now - lease => {
|
||||
row.started_at = now;
|
||||
Ok(Begin::Run)
|
||||
row.processed_at = now;
|
||||
Ok(Begin::Run(token(now)))
|
||||
}
|
||||
Some(row) => {
|
||||
let remaining = (row.started_at + lease - now)
|
||||
.to_std()
|
||||
.unwrap_or_default()
|
||||
.max(std::time::Duration::from_secs(1));
|
||||
Ok(Begin::InProgressElsewhere {
|
||||
retry_after: remaining,
|
||||
})
|
||||
}
|
||||
Some(_) => Ok(Begin::InProgressElsewhere),
|
||||
}
|
||||
}
|
||||
|
||||
async fn complete(&self, id: &EventId, handler: &str) -> Result<(), DomainError> {
|
||||
async fn complete(
|
||||
&self,
|
||||
id: &EventId,
|
||||
handler: &str,
|
||||
owner: &ClaimToken,
|
||||
) -> Result<(), DomainError> {
|
||||
if let Some(row) = self
|
||||
.processed_events
|
||||
.write()
|
||||
.unwrap()
|
||||
.get_mut(&(id.value(), handler.to_owned()))
|
||||
&& token(row.started_at) == *owner
|
||||
{
|
||||
row.done = true;
|
||||
row.processed_at = Utc::now().naive_utc();
|
||||
@@ -52,10 +82,18 @@ impl ProcessedEventLedger for InMemoryStore {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn release(&self, id: &EventId, handler: &str) -> Result<(), DomainError> {
|
||||
async fn release(
|
||||
&self,
|
||||
id: &EventId,
|
||||
handler: &str,
|
||||
owner: &ClaimToken,
|
||||
) -> Result<(), DomainError> {
|
||||
let mut rows = self.processed_events.write().unwrap();
|
||||
let key = (id.value(), handler.to_owned());
|
||||
if rows.get(&key).is_some_and(|r| !r.done) {
|
||||
if rows
|
||||
.get(&key)
|
||||
.is_some_and(|r| !r.done && token(r.started_at) == *owner)
|
||||
{
|
||||
rows.remove(&key);
|
||||
}
|
||||
Ok(())
|
||||
|
||||
Reference in New Issue
Block a user