use crate::engine::{Engine, WeakEngine};
use crate::error::{Error, Result};
use crate::export::{ExportedJournal, ReplayReport, Replayer};
use rustc_hash::{FxHashMap, FxHashSet};
use std::net::SocketAddr;
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, Ordering};
use std::thread::JoinHandle;
use std::time::Duration;
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub enum Profile {
#[default]
Production,
Dev,
}
impl Profile {
pub fn from_env() -> Result<Self> {
match std::env::var("FUSEE_PROFILE") {
Ok(v) => Self::parse(&v),
Err(_) => Ok(Self::Production),
}
}
pub fn parse(s: &str) -> Result<Self> {
match s {
"dev" => Ok(Self::Dev),
"production" | "" => Ok(Self::Production),
other => Err(Error::Rejected(format!(
"FUSEE_PROFILE is 'dev' or 'production', not '{other}'"
))),
}
}
}
const POLL: Duration = Duration::from_millis(250);
pub(crate) struct Dev {
stop: Arc<AtomicBool>,
checks: Option<JoinHandle<()>>,
http: Option<Served>,
}
impl Drop for Dev {
fn drop(&mut self) {
self.stop.store(true, Ordering::Release);
self.http.take();
join(self.checks.take());
}
}
fn join(t: Option<JoinHandle<()>>) {
if let Some(t) = t
&& t.thread().id() != std::thread::current().id()
{
let _ = t.join();
}
}
struct Served {
stop: Option<tokio::sync::oneshot::Sender<()>>,
thread: Option<JoinHandle<()>>,
}
impl Drop for Served {
fn drop(&mut self) {
if let Some(s) = self.stop.take() {
let _ = s.send(());
}
join(self.thread.take());
}
}
fn serve(engine: WeakEngine, addrs: Vec<String>, dev: bool) -> Result<(Served, SocketAddr)> {
let (tx, rx) = std::sync::mpsc::channel::<std::io::Result<SocketAddr>>();
let (stop, stopped) = tokio::sync::oneshot::channel::<()>();
let thread = std::thread::Builder::new()
.name("fusee-http".into())
.spawn(move || {
let rt = match tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
{
Ok(rt) => rt,
Err(e) => {
let _ = tx.send(Err(e));
return;
}
};
rt.block_on(async move {
let opts = crate::http::HttpOptions {
profile: dev.then(|| "dev".to_owned()),
..Default::default()
};
let mut last = None;
for a in &addrs {
match crate::http::serve(engine.clone(), a, opts.clone()).await {
Ok(server) => {
let _ = tx.send(Ok(server.addr()));
let _ = stopped.await;
drop(server);
return;
}
Err(e) => last = Some(e),
}
}
let _ = tx.send(Err(
last.unwrap_or_else(|| std::io::Error::other("no address"))
));
});
})?;
let addr = rx
.recv()
.map_err(|_| Error::Io("the HTTP thread exited".into()))?
.map_err(|e| Error::Io(e.to_string()))?;
Ok((
Served {
stop: Some(stop),
thread: Some(thread),
},
addr,
))
}
pub(crate) fn start(engine: &Engine, dev: bool, plain_fsync: bool) -> Result<()> {
let config = engine.config();
let addrs = match (&config.http, dev) {
(Some(a), _) if a == "off" => vec![],
(Some(a), _) => vec![a.clone()],
(None, false) => vec![],
(None, true) => match std::env::var("FUSEE_HTTP") {
Ok(a) if a == "off" => vec![],
Ok(a) if !a.is_empty() => vec![a],
_ => vec!["127.0.0.1:7601".into(), "127.0.0.1:0".into()],
},
};
let mut http = None;
let mut url = None;
if !addrs.is_empty() {
let (served, addr) = serve(engine.downgrade(), addrs, dev)?;
http = Some(served);
url = Some(format!("http://{addr}"));
}
if !dev {
if http.is_some() {
engine.set_dev(
Dev {
stop: Arc::new(AtomicBool::new(false)),
checks: None,
http,
},
url,
);
}
return Ok(());
}
#[cfg(feature = "testing")]
let quiet = config.test_hooks.is_some();
#[cfg(not(feature = "testing"))]
let quiet = false;
let where_ = match config.durability {
crate::engine::Durability::Memory => "memory".to_owned(),
crate::engine::Durability::Local => config.dir.display().to_string(),
crate::engine::Durability::Custom(_) => "custom storage".to_owned(),
};
if !quiet {
crate::log::info(format!(
"dev profile ({where_}): every run's history is kept, each run is replayed against the \
code when it waits or ends{}. Not for production.",
if plain_fsync {
", flushes use fsync (a process crash loses nothing; power loss may)"
} else {
""
}
));
}
if let Some(u) = &url {
crate::log::info(format!("UI and HTTP API at {u}"));
}
let stop = Arc::new(AtomicBool::new(false));
let weak = engine.downgrade();
let s = stop.clone();
let checks = std::thread::Builder::new()
.name("fusee-dev".into())
.spawn(move || checks(weak, s))?;
engine.set_dev(
Dev {
stop,
checks: Some(checks),
http,
},
url,
);
Ok(())
}
impl Engine {
pub async fn replay_check(&self) -> Result<Vec<ReplayReport>> {
let replayer = Replayer::sharing(self.registry(), self.config().codec.clone());
let mut out = Vec::new();
for r in self.list(usize::MAX).await? {
if r.wtype.starts_with("fusee.") || !settled(&r.status) {
continue;
}
let Some(d) = self.describe(&r.id).await? else {
continue;
};
let journal = ExportedJournal::from_detail(&d);
if !journal.is_complete() {
continue;
}
let report = replayer.replay(&journal);
if report.error.is_some() {
out.push(report);
}
}
Ok(out)
}
}
fn settled(status: &str) -> bool {
matches!(
status,
"suspended" | "parked" | "ok" | "failed" | "cancelled" | "timed_out" | "terminated"
)
}
fn checks(weak: WeakEngine, stop: Arc<AtomicBool>) {
let Ok(rt) = tokio::runtime::Builder::new_current_thread()
.enable_time()
.build()
else {
return;
};
let mut seen: FxHashMap<String, (String, Option<u64>)> = FxHashMap::default();
let mut reported: FxHashSet<String> = FxHashSet::default();
loop {
let mut slept = Duration::ZERO;
while slept < POLL {
if stop.load(Ordering::Acquire) {
return;
}
std::thread::sleep(Duration::from_millis(25));
slept += Duration::from_millis(25);
}
let Some(engine) = weak.upgrade() else { return };
if engine.is_closed() {
return;
}
let Ok(rows) = rt.block_on(engine.list(usize::MAX)) else {
continue;
};
let replayer = Replayer::sharing(engine.registry(), engine.config().codec.clone());
for r in rows {
if r.wtype.starts_with("fusee.") {
continue;
}
if r.status == "stuck" {
engine.reject_stuck(&r.id);
continue;
}
if !settled(&r.status) {
continue;
}
let at = (r.status.clone(), r.finished_ms);
if seen.get(&r.id) == Some(&at) {
continue;
}
seen.insert(r.id.clone(), at);
let Ok(Some(d)) = rt.block_on(engine.describe(&r.id)) else {
continue;
};
let journal = ExportedJournal::from_detail(&d);
if !journal.is_complete() {
continue;
}
let report = replayer.replay(&journal);
if let Some(e) = report.error
&& !e.starts_with("no code registered")
&& reported.insert(format!("{}\u{0}{e}", r.id))
{
let line = format!(
"strict replay: run '{}' ({} v{}) does not replay against its code: {e}. Code \
outside steps must make the same calls in the same order every time: read \
the clock, random numbers and unordered collections inside a step. {}",
r.id,
r.wtype,
r.version,
crate::error::docs_link("Determinism")
);
crate::log::emit(&crate::log::Record {
workflow_id: Some(&r.id),
workflow_type: Some(&r.wtype),
run: Some(r.run),
..crate::log::Record::engine(crate::log::Level::Warn, &line)
});
}
}
drop(engine);
}
}