use std::sync::Arc;
use std::time::Duration;
use futures_util::TryStreamExt;
use taquba::Queue;
use taquba::object_store::{ObjectStore, ObjectStoreExt, PutPayload, memory::InMemory, path::Path};
use taquba_workflow::{
RunId, RunOutcome, RunSpec, SignalOutcome, Step, StepError, StepOutcome, StepRunner,
TerminalEffects, TerminalHook, TerminalStatus, WorkflowRuntime,
};
use tokio::sync::{mpsc, oneshot};
const RUN_ID: &str = "translate-invoice-118";
const REPLY_TIMEOUT: Duration = Duration::from_secs(30);
const WATCH_INTERVAL: Duration = Duration::from_millis(500);
const REMOTE_WORK: Duration = Duration::from_secs(2);
const PENDING_PREFIX: &[u8] = b"remote/pending/";
fn correlation_key(run_id: &str, step_number: u32) -> String {
format!("remote:{run_id}:{step_number}")
}
fn pending_marker(correlation_key: &str) -> Vec<u8> {
[PENDING_PREFIX, correlation_key.as_bytes()].concat()
}
fn reply_key(run_id: &str, step_number: u32) -> String {
format!("replies/{run_id}/{step_number}")
}
struct Request {
text: String,
reply_key: String,
}
struct Dispatcher {
remote: mpsc::Sender<Request>,
store: Arc<dyn ObjectStore>,
}
impl StepRunner for Dispatcher {
async fn run_step(&self, step: &Step) -> Result<StepOutcome, StepError> {
match step.step_number {
0 => self.dispatch(step).await,
_ => self.collect(step).await,
}
}
}
impl Dispatcher {
async fn dispatch(&self, step: &Step) -> Result<StepOutcome, StepError> {
let text = String::from_utf8_lossy(&step.payload).into_owned();
let reply_key = reply_key(&step.run_id, step.step_number);
let key = correlation_key(&step.run_id, step.step_number);
println!(
"[step {}] sending {text:?}; reply expected at {reply_key}",
step.step_number
);
step.effects
.put(pending_marker(&key), reply_key.as_bytes())?;
self.remote
.send(Request { text, reply_key })
.await
.map_err(|_| StepError::transient("remote worker is gone"))?;
Ok(StepOutcome::continue_on_signal(
step.payload.clone(),
key,
REPLY_TIMEOUT,
))
}
async fn collect(&self, step: &Step) -> Result<StepOutcome, StepError> {
let Some(reply_key) = step.signal.as_deref() else {
let reason = format!("no reply within {}s", REPLY_TIMEOUT.as_secs());
println!("[step {}] {reason}", step.step_number);
return Ok(StepOutcome::Fail { reason });
};
let path = Path::from(String::from_utf8_lossy(reply_key).into_owned());
let reply = self
.store
.get(&path)
.await
.map_err(|e| StepError::transient(format!("reply read: {e}")))?
.bytes()
.await
.map_err(|e| StepError::transient(format!("reply read: {e}")))?;
println!(
"[step {}] reply at {path}: {:?}",
step.step_number,
String::from_utf8_lossy(&reply)
);
Ok(StepOutcome::Succeed {
result: reply.to_vec(),
})
}
}
async fn remote_worker(mut requests: mpsc::Receiver<Request>, store: Arc<dyn ObjectStore>) {
while let Some(request) = requests.recv().await {
tokio::time::sleep(REMOTE_WORK).await;
let reply = request.text.to_uppercase();
let path = Path::from(request.reply_key);
match store.put(&path, PutPayload::from(reply.into_bytes())).await {
Ok(_) => println!("[remote] wrote the reply to {path}"),
Err(e) => println!("[remote] failed to write {path}: {e}"),
}
}
}
async fn watch_replies(
queue: Arc<Queue>,
store: Arc<dyn ObjectStore>,
runtime: WorkflowRuntime<Dispatcher, ShutdownOnTermination>,
) {
loop {
tokio::time::sleep(WATCH_INTERVAL).await;
if let Err(e) = watch_once(&queue, &store, &runtime).await {
println!("[watcher] pass failed: {e}");
}
}
}
async fn watch_once(
queue: &Queue,
store: &Arc<dyn ObjectStore>,
runtime: &WorkflowRuntime<Dispatcher, ShutdownOnTermination>,
) -> Result<(), Box<dyn std::error::Error>> {
let mut markers = std::pin::pin!(queue.view().kv_entries(PENDING_PREFIX, .., 64));
while let Some((marker, reply_key)) = markers.try_next().await? {
let path = Path::from(String::from_utf8_lossy(&reply_key).into_owned());
match store.head(&path).await {
Ok(_) => {}
Err(taquba::object_store::Error::NotFound { .. }) => continue,
Err(e) => return Err(e.into()),
}
let key = String::from_utf8_lossy(&marker[PENDING_PREFIX.len()..]).into_owned();
match runtime.signal(&key, reply_key.to_vec()).await? {
SignalOutcome::Delivered => println!("[watcher] {path} arrived; woke {key}"),
SignalOutcome::Buffered => {
println!("[watcher] {path} arrived after the wait ended; discarding the signal");
runtime.clear_signal(&key).await?;
}
}
queue.kv_delete(&marker).await?;
}
Ok(())
}
struct ShutdownOnTermination {
shutdown: tokio::sync::Mutex<Option<oneshot::Sender<()>>>,
}
impl TerminalHook for ShutdownOnTermination {
async fn on_termination(
&self,
outcome: &RunOutcome,
_effects: &TerminalEffects,
) -> std::result::Result<(), StepError> {
match outcome.status {
TerminalStatus::Succeeded => println!(
"run {} succeeded: {:?}",
outcome.run_id,
String::from_utf8_lossy(outcome.result.as_deref().unwrap_or(&[]))
),
_ => println!(
"run {} terminated as {:?}: {}",
outcome.run_id,
outcome.status,
outcome.error.as_deref().unwrap_or("(no error)")
),
}
if let Some(tx) = self.shutdown.lock().await.take() {
let _ = tx.send(());
}
Ok(())
}
}
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
let store: Arc<dyn ObjectStore> = Arc::new(InMemory::new());
let queue = Arc::new(Queue::open(store.clone(), "workflow").await?);
let (requests, inbox) = mpsc::channel::<Request>(8);
let (tx, rx) = oneshot::channel::<()>();
let runtime = WorkflowRuntime::builder(
queue.clone(),
store.clone(),
Dispatcher {
remote: requests,
store: store.clone(),
},
ShutdownOnTermination {
shutdown: tokio::sync::Mutex::new(Some(tx)),
},
)
.poll_interval(Duration::from_millis(200))
.build();
tokio::spawn(remote_worker(inbox, store.clone()));
tokio::spawn(watch_replies(queue.clone(), store, runtime.clone()));
let outcome = runtime
.submit(RunSpec {
run_id: Some(RunId::new(RUN_ID)?),
input: b"invoice 118: three chairs, one table".to_vec(),
..Default::default()
})
.await?;
println!("submitted run {}", outcome.run_id);
runtime
.run(async move {
let _ = rx.await;
})
.await?;
Ok(())
}