use std::{
collections::HashMap,
sync::Arc,
time::{Duration, Instant},
};
use tokio::sync::Mutex;
use uuid::Uuid;
use crate::{Result, VoidCrawlError};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum InterruptState {
Interrupted,
Resumed,
Released,
Expired,
}
impl InterruptState {
#[must_use]
pub const fn as_str(self) -> &'static str {
match self {
Self::Interrupted => "interrupted",
Self::Resumed => "resumed",
Self::Released => "released",
Self::Expired => "expired",
}
}
const fn terminal(self) -> bool {
!matches!(self, Self::Interrupted)
}
}
#[derive(Debug, Clone)]
pub struct InterruptRequest {
pub code: String,
pub summary: String,
pub ttl: Duration,
}
impl InterruptRequest {
pub fn validate(&self) -> Result<()> {
let code = self.code.as_bytes();
let valid_code = !code.is_empty()
&& code.len() <= 128
&& matches!(code[0], b'a'..=b'z' | b'0'..=b'9')
&& code
.iter()
.all(|byte| matches!(byte, b'a'..=b'z' | b'0'..=b'9' | b'.' | b'_' | b'-'));
if !valid_code {
return Err(VoidCrawlError::InvalidInterruptRequest(
"code must be 1-128 lowercase policy characters".into(),
));
}
if self.summary.trim().is_empty() || self.summary.chars().count() > 512 {
return Err(VoidCrawlError::InvalidInterruptRequest(
"summary must be 1-512 non-blank characters".into(),
));
}
if self.ttl.is_zero() || self.ttl > Duration::from_secs(3600) {
return Err(VoidCrawlError::InvalidInterruptRequest(
"ttl must be between one second and one hour".into(),
));
}
Ok(())
}
}
#[derive(Debug, Clone)]
pub struct InterruptInfo {
pub interrupt_id: String,
pub target_id: String,
pub code: String,
pub summary: String,
pub state: InterruptState,
pub expires_in: Duration,
}
#[derive(Debug, Clone)]
struct InterruptRecord {
info: InterruptInfo,
expires_at: Instant,
}
#[derive(Debug, Default)]
pub struct InterruptRegistry {
records: Mutex<HashMap<String, InterruptRecord>>,
}
impl InterruptRegistry {
#[must_use]
pub fn new() -> Arc<Self> {
Arc::new(Self::default())
}
pub async fn interrupt(
&self,
target_id: String,
request: InterruptRequest,
) -> Result<InterruptInfo> {
request.validate()?;
let mut records = self.records.lock().await;
Self::expire_due(&mut records);
if records.values().any(|record| {
record.info.target_id == target_id && record.info.state == InterruptState::Interrupted
}) {
return Err(VoidCrawlError::InterruptAlreadyActive { target_id });
}
let expires_at = Instant::now() + request.ttl;
let info = InterruptInfo {
interrupt_id: Uuid::new_v4().to_string(),
target_id,
code: request.code,
summary: request.summary,
state: InterruptState::Interrupted,
expires_in: request.ttl,
};
records
.insert(info.interrupt_id.clone(), InterruptRecord { info: info.clone(), expires_at });
Ok(info)
}
pub async fn status(&self, interrupt_id: &str) -> Result<InterruptInfo> {
let mut records = self.records.lock().await;
Self::expire_due(&mut records);
let record = records.get(interrupt_id).ok_or_else(|| {
VoidCrawlError::InterruptNotFound { interrupt_id: interrupt_id.into() }
})?;
let mut info = record.info.clone();
if info.state == InterruptState::Interrupted {
info.expires_in = record.expires_at.saturating_duration_since(Instant::now());
}
Ok(info)
}
pub async fn resume(&self, interrupt_id: &str) -> Result<InterruptInfo> {
self.transition(interrupt_id, InterruptState::Resumed).await
}
pub async fn release(&self, interrupt_id: &str) -> Result<InterruptInfo> {
self.transition(interrupt_id, InterruptState::Released).await
}
pub async fn page_is_active(&self, target_id: &str) -> Result<()> {
let mut records = self.records.lock().await;
Self::expire_due(&mut records);
if let Some(record) = records.values().find(|record| {
record.info.target_id == target_id && record.info.state == InterruptState::Interrupted
}) {
return Err(VoidCrawlError::SessionInterrupted {
interrupt_id: record.info.interrupt_id.clone(),
});
}
if let Some(record) = records.values().find(|record| {
record.info.target_id == target_id && record.info.state == InterruptState::Expired
}) {
return Err(VoidCrawlError::InterruptExpired {
interrupt_id: record.info.interrupt_id.clone(),
});
}
Ok(())
}
async fn transition(&self, interrupt_id: &str, next: InterruptState) -> Result<InterruptInfo> {
let mut records = self.records.lock().await;
Self::expire_due(&mut records);
let record = records.get_mut(interrupt_id).ok_or_else(|| {
VoidCrawlError::InterruptNotFound { interrupt_id: interrupt_id.into() }
})?;
if record.info.state == InterruptState::Expired {
return Err(VoidCrawlError::InterruptExpired { interrupt_id: interrupt_id.into() });
}
if record.info.state.terminal() {
return Err(VoidCrawlError::InterruptTerminal {
interrupt_id: interrupt_id.into(),
state: record.info.state.as_str().into(),
});
}
record.info.state = next;
record.info.expires_in = Duration::ZERO;
Ok(record.info.clone())
}
fn expire_due(records: &mut HashMap<String, InterruptRecord>) {
let now = Instant::now();
for record in records.values_mut() {
if record.info.state == InterruptState::Interrupted && now >= record.expires_at {
record.info.state = InterruptState::Expired;
record.info.expires_in = Duration::ZERO;
}
}
}
}
#[cfg(test)]
mod tests {
use std::time::Duration;
use tokio::time::sleep;
use super::{InterruptRegistry, InterruptRequest, InterruptState};
use crate::VoidCrawlError;
#[tokio::test]
async fn resume_reactivates_target_without_dropping_record() -> crate::Result<()> {
let registry = InterruptRegistry::new();
let info = registry
.interrupt(
"target-1".into(),
InterruptRequest {
code: "policy.review".into(),
summary: "Review required".into(),
ttl: Duration::from_secs(1),
},
)
.await?;
assert!(registry.page_is_active("target-1").await.is_err());
let resumed = registry.resume(&info.interrupt_id).await?;
assert_eq!(resumed.state, InterruptState::Resumed);
assert!(registry.page_is_active("target-1").await.is_ok());
assert!(registry.resume(&info.interrupt_id).await.is_err());
Ok(())
}
#[tokio::test]
async fn expires_parked_target() -> crate::Result<()> {
let registry = InterruptRegistry::new();
let info = registry
.interrupt(
"target-1".into(),
InterruptRequest {
code: "policy.review".into(),
summary: "Review required".into(),
ttl: Duration::from_millis(1),
},
)
.await?;
sleep(Duration::from_millis(5)).await;
let expired = registry.status(&info.interrupt_id).await?;
assert_eq!(expired.state, InterruptState::Expired);
assert!(matches!(
registry.page_is_active("target-1").await,
Err(VoidCrawlError::InterruptExpired { .. })
));
assert!(registry.resume(&info.interrupt_id).await.is_err());
Ok(())
}
#[tokio::test]
async fn rejects_invalid_policy_code_and_unbounded_ttl() {
let registry = InterruptRegistry::new();
assert!(
registry
.interrupt(
"target-1".into(),
InterruptRequest {
code: "Policy Review".into(),
summary: "Review required".into(),
ttl: Duration::from_secs(1),
},
)
.await
.is_err()
);
assert!(
registry
.interrupt(
"target-1".into(),
InterruptRequest {
code: "policy.review".into(),
summary: "Review required".into(),
ttl: Duration::from_secs(3601),
},
)
.await
.is_err()
);
}
#[tokio::test]
async fn rejects_blank_request_before_creating_interrupt() {
let registry = InterruptRegistry::new();
assert!(
registry
.interrupt(
"target-1".into(),
InterruptRequest {
code: " ".into(),
summary: "Review required".into(),
ttl: Duration::from_secs(1),
},
)
.await
.is_err()
);
}
}