use crate::types::TenantId;
use async_trait::async_trait;
use futures_core::stream::BoxStream;
use serde::{Deserialize, Serialize};
use thiserror::Error;
#[derive(Clone, Debug, Hash, Eq, PartialEq, Serialize, Deserialize)]
pub struct WorkId(pub String);
impl std::fmt::Display for WorkId {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(&self.0)
}
}
#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
#[non_exhaustive]
pub enum WorkStatus {
Planned,
Ready,
InProgress,
Done,
Failed,
AwaitingApproval,
Cancelled,
}
impl WorkStatus {
#[must_use]
pub fn is_terminal(self) -> bool {
matches!(self, Self::Done | Self::Failed | Self::Cancelled)
}
}
#[derive(Clone, Debug, Serialize, Deserialize)]
#[non_exhaustive]
pub struct WorkItem {
pub id: WorkId,
pub tenant: Option<TenantId>,
pub title: String,
pub payload: serde_json::Value,
pub status: WorkStatus,
pub depends_on: Vec<WorkId>,
pub last_transition_at: String,
}
impl WorkItem {
pub fn new(
title: impl Into<String>,
payload: serde_json::Value,
tenant: Option<TenantId>,
depends_on: Vec<WorkId>,
) -> Self {
Self {
id: WorkId(String::new()),
tenant,
title: title.into(),
payload,
status: WorkStatus::Planned,
depends_on,
last_transition_at: String::new(),
}
}
}
#[derive(Clone, Debug)]
#[non_exhaustive]
pub struct WorkDag {
pub items: Vec<WorkItem>,
}
#[derive(Debug, Error)]
#[non_exhaustive]
pub enum WorkLogError {
#[error("worklog storage unavailable: {message}")]
Storage {
message: String,
#[source]
source: Option<Box<dyn std::error::Error + Send + Sync + 'static>>,
},
#[error("unknown work item: {0}")]
UnknownWorkItem(WorkId),
#[error("dependency would create a cycle: {child} -> {on}")]
CycleDetected {
child: WorkId,
on: WorkId,
},
#[error("DAG size cap {cap} exceeded")]
CapExceeded {
cap: usize,
},
#[error("dispatch failed: {0}")]
DispatchFailed(String),
#[error("internal: {0}")]
Internal(String),
}
#[async_trait]
pub trait WorkLog: Send + Sync {
async fn plan(&self, item: WorkItem) -> Result<WorkId, WorkLogError>;
async fn depend(&self, child: WorkId, on: WorkId) -> Result<(), WorkLogError>;
async fn ready(&self, limit: usize) -> Vec<WorkId>;
async fn ready_stream(&self) -> BoxStream<'static, WorkId>;
async fn dispatch(&self, id: WorkId) -> Result<(), WorkLogError>;
async fn transition(&self, id: WorkId, status: WorkStatus) -> Result<(), WorkLogError>;
async fn transition_if_status(
&self,
id: WorkId,
from: WorkStatus,
to: WorkStatus,
) -> Result<bool, WorkLogError> {
match self.get(id.clone()).await {
Some(item) if item.status == from => {
self.transition(id, to).await?;
Ok(true)
}
Some(_) => Ok(false),
None => Err(WorkLogError::UnknownWorkItem(id)),
}
}
async fn get(&self, id: WorkId) -> Option<WorkItem>;
async fn dag(&self, root: WorkId) -> WorkDag;
async fn list_by_status(&self, filter: WorkStatus, limit: usize) -> Vec<WorkItem> {
let _ = (filter, limit);
Vec::new()
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::error::Error as StdError;
#[test]
fn storage_preserves_source_chain() {
let inner = std::io::Error::other("kv connection refused");
let e = WorkLogError::Storage {
message: "fetch item".into(),
source: Some(Box::new(inner)),
};
assert_eq!(e.to_string(), "worklog storage unavailable: fetch item");
let src = e.source().expect("source must be preserved");
assert_eq!(src.to_string(), "kv connection refused");
}
#[test]
fn storage_without_source_returns_none() {
let e = WorkLogError::Storage {
message: "cas exhaustion".into(),
source: None,
};
assert!(e.source().is_none());
}
}