use std::collections::HashMap;
use std::sync::Arc;
use std::time::{SystemTime, UNIX_EPOCH};
use async_trait::async_trait;
use iroh::EndpointId as NodeId;
use iroh_blobs::Hash;
use serde::{Deserialize, Serialize};
use super::protocol::CompletedTask;
use super::{ResourceLimits, TaskClass};
pub type TaskKey = String;
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub enum TaskState {
Pending,
Running { deadline_unix: u64 },
Done {
executor: NodeId,
},
Failed { error: String },
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct TaskRecord {
pub key: TaskKey,
pub wasm_hash: Hash,
pub entrypoint: String,
pub class: TaskClass,
pub limits: ResourceLimits,
pub input: Vec<u8>,
pub required_model: Option<String>,
pub state: TaskState,
pub attempts: u32,
pub created_at_unix: u64,
pub updated_at_unix: u64,
pub result: Option<CompletedTask>,
}
impl TaskRecord {
#[allow(clippy::too_many_arguments)]
pub fn pending(
key: TaskKey,
wasm_hash: Hash,
entrypoint: String,
class: TaskClass,
limits: ResourceLimits,
input: Vec<u8>,
required_model: Option<String>,
) -> Self {
let now = unix_now();
Self {
key,
wasm_hash,
entrypoint,
class,
limits,
input,
required_model,
state: TaskState::Pending,
attempts: 0,
created_at_unix: now,
updated_at_unix: now,
result: None,
}
}
pub fn needs_dispatch(&self, now_unix: u64) -> bool {
match &self.state {
TaskState::Pending => true,
TaskState::Running { deadline_unix } => *deadline_unix < now_unix,
TaskState::Done { .. } | TaskState::Failed { .. } => false,
}
}
}
#[async_trait]
pub trait LedgerStore: Send + Sync + 'static {
async fn get(&self, key: &str) -> Result<Option<Vec<u8>>, String>;
async fn put(&self, key: &str, value: Vec<u8>) -> Result<(), String>;
async fn create_if_absent(&self, key: &str, value: Vec<u8>) -> Result<bool, String>;
async fn compare_and_swap(
&self,
key: &str,
expected: Option<Vec<u8>>,
value: Vec<u8>,
) -> Result<bool, String>;
async fn list(&self) -> Result<Vec<Vec<u8>>, String>;
}
#[derive(Debug, Default)]
pub struct MemoryLedger {
entries: parking_lot::Mutex<HashMap<String, Vec<u8>>>,
}
impl MemoryLedger {
pub fn new() -> Self {
Self::default()
}
}
#[async_trait]
impl LedgerStore for MemoryLedger {
async fn get(&self, key: &str) -> Result<Option<Vec<u8>>, String> {
Ok(self.entries.lock().get(key).cloned())
}
async fn put(&self, key: &str, value: Vec<u8>) -> Result<(), String> {
self.entries.lock().insert(key.to_string(), value);
Ok(())
}
async fn create_if_absent(&self, key: &str, value: Vec<u8>) -> Result<bool, String> {
let mut entries = self.entries.lock();
if entries.contains_key(key) {
return Ok(false);
}
entries.insert(key.to_string(), value);
Ok(true)
}
async fn compare_and_swap(
&self,
key: &str,
expected: Option<Vec<u8>>,
value: Vec<u8>,
) -> Result<bool, String> {
let mut entries = self.entries.lock();
if entries.get(key).map(|v| v.as_slice()) == expected.as_deref() {
entries.insert(key.to_string(), value);
Ok(true)
} else {
Ok(false)
}
}
async fn list(&self) -> Result<Vec<Vec<u8>>, String> {
Ok(self.entries.lock().values().cloned().collect())
}
}
#[derive(Clone)]
pub struct TaskLedger {
store: Arc<dyn LedgerStore>,
}
impl std::fmt::Debug for TaskLedger {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("TaskLedger").finish_non_exhaustive()
}
}
impl TaskLedger {
pub fn new(store: Arc<dyn LedgerStore>) -> Self {
Self { store }
}
pub fn in_memory() -> Self {
Self::new(Arc::new(MemoryLedger::new()))
}
pub async fn claim(&self, record: &TaskRecord) -> Result<bool, String> {
let bytes = postcard::to_stdvec(record).map_err(|e| e.to_string())?;
self.store.create_if_absent(&record.key, bytes).await
}
pub async fn get(&self, key: &str) -> Result<Option<TaskRecord>, String> {
match self.store.get(key).await? {
Some(bytes) => postcard::from_bytes(&bytes)
.map(Some)
.map_err(|e| e.to_string()),
None => Ok(None),
}
}
pub async fn claim_for_dispatch(
&self,
key: &str,
deadline_unix: u64,
) -> Result<Option<TaskRecord>, String> {
loop {
let now = unix_now();
let Some(bytes) = self.store.get(key).await? else {
return Ok(None);
};
let record: TaskRecord = postcard::from_bytes(&bytes).map_err(|e| e.to_string())?;
if !record.needs_dispatch(now) {
return Ok(None); }
let mut running = record.clone();
running.attempts += 1;
running.state = TaskState::Running { deadline_unix };
running.updated_at_unix = now;
let new_bytes = postcard::to_stdvec(&running).map_err(|e| e.to_string())?;
if self
.store
.compare_and_swap(key, Some(bytes), new_bytes)
.await?
{
return Ok(Some(running));
}
}
}
pub async fn update<F>(&self, key: &str, mutate: F) -> Result<Option<TaskRecord>, String>
where
F: FnOnce(&mut TaskRecord),
{
let Some(mut record) = self.get(key).await? else {
return Ok(None);
};
mutate(&mut record);
record.updated_at_unix = unix_now();
let bytes = postcard::to_stdvec(&record).map_err(|e| e.to_string())?;
self.store.put(key, bytes).await?;
Ok(Some(record))
}
pub async fn all(&self) -> Result<Vec<TaskRecord>, String> {
self.store
.list()
.await?
.into_iter()
.map(|bytes| postcard::from_bytes(&bytes).map_err(|e| e.to_string()))
.collect()
}
pub async fn needing_dispatch(&self) -> Result<Vec<TaskRecord>, String> {
let now = unix_now();
Ok(self
.all()
.await?
.into_iter()
.filter(|r| r.needs_dispatch(now))
.collect())
}
}
pub(crate) fn unix_now() -> u64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_secs()
}
#[cfg(test)]
mod tests {
use super::*;
fn record(key: &str) -> TaskRecord {
TaskRecord::pending(
key.to_string(),
Hash::new(b"wasm"),
"gdb_run".into(),
TaskClass::Media,
ResourceLimits::default(),
b"photo bytes".to_vec(),
None,
)
}
#[tokio::test]
async fn claim_is_idempotent() {
let ledger = TaskLedger::in_memory();
let rec = record("task-1");
assert!(ledger.claim(&rec).await.unwrap(), "first claim wins");
assert!(!ledger.claim(&rec).await.unwrap(), "second claim loses");
let stored = ledger.get("task-1").await.unwrap().unwrap();
assert_eq!(stored.state, TaskState::Pending);
assert_eq!(stored.attempts, 0);
}
#[tokio::test]
async fn lifecycle_transitions_and_result() {
let ledger = TaskLedger::in_memory();
ledger.claim(&record("t")).await.unwrap();
let running = ledger
.update("t", |r| {
r.attempts += 1;
r.state = TaskState::Running {
deadline_unix: unix_now() + 60,
};
})
.await
.unwrap()
.unwrap();
assert_eq!(running.attempts, 1);
assert!(
!running.needs_dispatch(unix_now()),
"running within deadline"
);
let executor = iroh::SecretKey::generate().public();
let done = ledger
.update("t", |r| {
r.state = TaskState::Done { executor };
r.result = Some(CompletedTask {
output: b"thumb".to_vec(),
metrics: crate::compute::ExecMetrics {
fuel_consumed: 42,
duration_ms: 7,
peak_memory_bytes: 65_536,
},
});
})
.await
.unwrap()
.unwrap();
assert!(
!done.needs_dispatch(unix_now() + 10_000),
"done is terminal"
);
assert_eq!(done.result.unwrap().output, b"thumb");
}
#[tokio::test]
async fn expired_running_needs_dispatch_again() {
let ledger = TaskLedger::in_memory();
ledger.claim(&record("t")).await.unwrap();
ledger
.update("t", |r| {
r.attempts = 1;
r.state = TaskState::Running {
deadline_unix: unix_now() - 5, };
})
.await
.unwrap();
let due = ledger.needing_dispatch().await.unwrap();
assert_eq!(due.len(), 1);
assert_eq!(due[0].key, "t");
}
#[tokio::test]
async fn update_of_missing_key_is_none() {
let ledger = TaskLedger::in_memory();
assert!(ledger.update("ghost", |_| {}).await.unwrap().is_none());
}
#[tokio::test]
async fn only_one_dispatcher_claims_a_pending_task() {
let ledger = TaskLedger::in_memory();
ledger.claim(&record("t")).await.unwrap();
let deadline = unix_now() + 60;
let (a, b) = tokio::join!(
ledger.claim_for_dispatch("t", deadline),
ledger.claim_for_dispatch("t", deadline),
);
let claimed = [a.unwrap(), b.unwrap()];
assert_eq!(
claimed.iter().filter(|c| c.is_some()).count(),
1,
"exactly one claim wins"
);
let winner = claimed.iter().find_map(|c| c.clone()).unwrap();
assert_eq!(winner.attempts, 1);
assert!(matches!(winner.state, TaskState::Running { .. }));
assert!(
ledger
.claim_for_dispatch("t", deadline)
.await
.unwrap()
.is_none()
);
}
#[tokio::test]
async fn claim_for_dispatch_ignores_terminal_and_absent() {
let ledger = TaskLedger::in_memory();
assert!(
ledger
.claim_for_dispatch("ghost", unix_now() + 60)
.await
.unwrap()
.is_none(),
"absent key"
);
ledger.claim(&record("done")).await.unwrap();
let executor = iroh::SecretKey::generate().public();
ledger
.update("done", |r| r.state = TaskState::Done { executor })
.await
.unwrap();
assert!(
ledger
.claim_for_dispatch("done", unix_now() + 60)
.await
.unwrap()
.is_none(),
"terminal task is not re-claimed"
);
}
}