use std::path::{Path, PathBuf};
use super::RelayDelivery;
use super::claim::{Claim, ClaimOutcome};
use super::inbox::{Inbox, InboxError};
use super::retry::{self, DEFAULT_MAX_ATTEMPTS};
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Disposition {
Processed,
Ignored {
reason: String,
},
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ProcessFailure {
pub reason: String,
pub retryable: bool,
}
impl ProcessFailure {
pub fn retryable(reason: impl Into<String>) -> Self {
Self {
reason: reason.into(),
retryable: true,
}
}
pub fn permanent(reason: impl Into<String>) -> Self {
Self {
reason: reason.into(),
retryable: false,
}
}
}
#[async_trait::async_trait]
pub trait DeliveryProcessor: Send + Sync + 'static {
async fn process(&self, delivery: &RelayDelivery) -> Result<Disposition, ProcessFailure>;
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct DrainPolicy {
pub max_attempts: u32,
}
impl Default for DrainPolicy {
fn default() -> Self {
Self {
max_attempts: DEFAULT_MAX_ATTEMPTS,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum FailureOutcome {
Retrying,
Quarantined,
Stuck,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct DrainFailure {
pub delivery_id: String,
pub path: PathBuf,
pub attempts: u32,
pub reason: String,
pub outcome: FailureOutcome,
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct DrainReport {
pub scanned: usize,
pub processed: usize,
pub ignored: usize,
pub retry_pending: usize,
pub quarantined: usize,
pub skipped_in_flight: usize,
pub vanished: usize,
pub deduplicated: usize,
pub failures: Vec<DrainFailure>,
pub scan_error: Option<String>,
}
impl DrainReport {
pub fn accounted(&self) -> usize {
self.processed
+ self.ignored
+ self.retry_pending
+ self.quarantined
+ self.skipped_in_flight
+ self.vanished
+ self.deduplicated
+ self
.failures
.iter()
.filter(|f| f.outcome == FailureOutcome::Stuck)
.count()
}
pub fn is_clean(&self) -> bool {
self.scan_error.is_none() && self.failures.is_empty() && self.quarantined == 0
}
pub fn log_summary(&self, source: &str) {
if let Some(e) = &self.scan_error {
tracing::error!(source, error = %e, "webhook inbox could not be listed; nothing was drained");
return;
}
if self.is_clean() {
if self.processed > 0 || self.ignored > 0 {
tracing::info!(
source,
processed = self.processed,
ignored = self.ignored,
skipped_in_flight = self.skipped_in_flight,
"drained the webhook inbox"
);
}
return;
}
for failure in &self.failures {
tracing::error!(
source,
delivery_id = %failure.delivery_id,
path = %failure.path.display(),
attempts = failure.attempts,
outcome = ?failure.outcome,
reason = %failure.reason,
"webhook delivery was not processed"
);
}
tracing::error!(
source,
scanned = self.scanned,
processed = self.processed,
retry_pending = self.retry_pending,
quarantined = self.quarantined,
"webhook inbox drain finished with unprocessed deliveries"
);
}
fn quarantine(&mut self, root: &Path, path: &Path, id: &str, attempts: u32, reason: &str) {
match retry::quarantine(root, path) {
Ok(target) => {
self.quarantined += 1;
self.failures.push(DrainFailure {
delivery_id: id.to_string(),
path: target,
attempts,
reason: reason.to_string(),
outcome: FailureOutcome::Quarantined,
});
}
Err(e) => {
self.failures.push(DrainFailure {
delivery_id: id.to_string(),
path: path.to_path_buf(),
attempts,
reason: format!("{reason}; and quarantining it failed: {e}"),
outcome: FailureOutcome::Stuck,
});
}
}
}
fn accept(&mut self, root: &Path, path: &Path, id: &str, disposition: &Disposition) {
if let Err(e) = retry::mark_processed(root, path, id, now_unix_ms()) {
self.failures.push(DrainFailure {
delivery_id: id.to_string(),
path: path.to_path_buf(),
attempts: 0,
reason: format!(
"the pipeline accepted this delivery but it could not be recorded as \
processed, so the entry is kept rather than removed unrecorded: {e}"
),
outcome: FailureOutcome::Stuck,
});
return;
}
match retry::remove_processed(path) {
Ok(()) => match disposition {
Disposition::Processed => self.processed += 1,
Disposition::Ignored { reason } => {
self.ignored += 1;
tracing::info!(delivery_id = %id, reason = %reason, "webhook delivery needed no work");
}
},
Err(e) => self.failures.push(DrainFailure {
delivery_id: id.to_string(),
path: path.to_path_buf(),
attempts: 0,
reason: format!(
"the pipeline accepted this delivery but its inbox entry could not be \
removed; the processed ledger stops it running again: {e}"
),
outcome: FailureOutcome::Stuck,
}),
}
}
}
pub async fn drain_once(
inbox: &Inbox,
processor: &dyn DeliveryProcessor,
policy: DrainPolicy,
) -> DrainReport {
let root = inbox.root().to_path_buf();
let mut report = DrainReport::default();
let entries = match candidate_entries(&root) {
Ok(entries) => entries,
Err(e) => {
report.scan_error = Some(format!("{e}"));
return report;
}
};
report.scanned = entries.len();
retry::prune_processed(&root, retry::PROCESSED_RETENTION);
for path in entries {
let claim = match Claim::try_acquire(&path) {
Ok(ClaimOutcome::Claimed(claim)) => claim,
Ok(ClaimOutcome::InFlight) => {
report.skipped_in_flight += 1;
continue;
}
Ok(ClaimOutcome::Vanished) => {
report.vanished += 1;
continue;
}
Ok(ClaimOutcome::Undecodable { path, reason }) => {
report.quarantine(
&root,
&path,
"<undecodable>",
0,
&format!("inbox entry is not a decodable delivery: {reason}"),
);
continue;
}
Err(e) => {
report.failures.push(DrainFailure {
delivery_id: "<unclaimed>".to_string(),
path,
attempts: 0,
reason: format!("{e}"),
outcome: FailureOutcome::Stuck,
});
continue;
}
};
let id = claim.delivery().delivery_id.clone();
if retry::is_processed(&root, &path) {
tracing::warn!(
delivery_id = %id,
"webhook delivery was already processed; removing its entry without re-running \
the pipeline (a previous drainer died between accepting it and removing it)"
);
match retry::remove_processed(&path) {
Ok(()) => report.deduplicated += 1,
Err(e) => report.failures.push(DrainFailure {
delivery_id: id,
path: path.clone(),
attempts: 0,
reason: format!("already processed, but its entry could not be removed: {e}"),
outcome: FailureOutcome::Stuck,
}),
}
continue;
}
let already = retry::load_attempts(&path);
if already.attempts >= policy.max_attempts {
report.quarantine(
&root,
&path,
&id,
already.attempts,
&format!(
"out of retries after {} failed attempts; last error: {}",
already.attempts, already.last_error
),
);
continue;
}
match processor.process(claim.delivery()).await {
Ok(disposition) => report.accept(&root, &path, &id, &disposition),
Err(failure) => {
let record = match retry::record_failure(&path, &failure.reason, now_unix_ms()) {
Ok(record) => record,
Err(e) => {
report.quarantine(
&root,
&path,
&id,
0,
&format!(
"{}; and the attempt record could not be written, so the retry \
bound cannot be enforced: {e}",
failure.reason
),
);
continue;
}
};
if !failure.retryable || record.attempts >= policy.max_attempts {
let why = if failure.retryable {
format!(
"{} (attempt {} of {})",
failure.reason, record.attempts, policy.max_attempts
)
} else {
format!("{} (permanent; not retried)", failure.reason)
};
report.quarantine(&root, &path, &id, record.attempts, &why);
} else {
report.retry_pending += 1;
report.failures.push(DrainFailure {
delivery_id: id,
path: path.clone(),
attempts: record.attempts,
reason: failure.reason,
outcome: FailureOutcome::Retrying,
});
}
}
}
}
report
}
fn candidate_entries(root: &Path) -> Result<Vec<PathBuf>, InboxError> {
let read = match std::fs::read_dir(root) {
Ok(read) => read,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(Vec::new()),
Err(source) => {
return Err(InboxError::Read {
path: root.to_path_buf(),
source,
});
}
};
let mut entries: Vec<(std::time::SystemTime, PathBuf)> = read
.flatten()
.map(|e| e.path())
.filter(|p| p.extension().is_some_and(|x| x == "json"))
.map(|p| {
let mtime = std::fs::metadata(&p)
.and_then(|m| m.modified())
.unwrap_or(std::time::UNIX_EPOCH);
(mtime, p)
})
.collect();
entries.sort();
Ok(entries.into_iter().map(|(_, p)| p).collect())
}
fn now_unix_ms() -> u64 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_millis() as u64)
.unwrap_or(0)
}