use std::collections::{BTreeMap, BTreeSet};
use std::path::{Path, PathBuf};
use std::sync::Arc;
use std::time::Duration as StdDuration;
use futures::StreamExt;
use harn_vm::event_log::{install_default_for_base_dir, AnyEventLog, EventLog, Topic};
use harn_vm::triggers::event::{GenericWebhookPayload, KnownProviderPayload};
use harn_vm::{
dynamic_register, resolve_live_trigger_binding, DispatchOutcome, DispatchStatus, Dispatcher,
MetricsRegistry, ProviderId, ProviderPayload, RateLimitConfig, RateLimiterFactory, RetryPolicy,
SignatureStatus, TriggerBindingSource, TriggerBindingSpec, TriggerEvent, TriggerHandlerSpec,
TriggerRetryConfig, Vm, WorkerQueue, WorkerQueuePriority, WorkerQueueResponseRecord,
};
use tokio::sync::{broadcast, watch};
use tokio::task::JoinHandle;
use crate::limits::BudgetSpec;
use crate::{
DispatchError, ExportCatalog, ExportedFunction, JobSpec, RetryBackoff, RetrySpec, ScheduleSpec,
};
const JOB_PROVIDER: &str = "webhook";
const CRON_PROVIDER: &str = "cron";
const CRON_KIND: &str = "cron";
const DEFAULT_CLAIM_TTL: StdDuration = StdDuration::from_mins(5);
const DEFAULT_SHUTDOWN_DRAIN: StdDuration = StdDuration::from_secs(30);
#[derive(Clone, Debug)]
pub struct WorkerServeOptions {
pub consumer_id: Option<String>,
pub claim_ttl: StdDuration,
pub drain_timeout: StdDuration,
}
impl Default for WorkerServeOptions {
fn default() -> Self {
Self {
consumer_id: None,
claim_ttl: DEFAULT_CLAIM_TTL,
drain_timeout: DEFAULT_SHUTDOWN_DRAIN,
}
}
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct WorkerJobRegistration {
pub job: String,
pub function: String,
pub binding_id: String,
pub binding_key: String,
pub binding_version: u32,
pub schedule: Option<ScheduleSpec>,
pub queue: Option<String>,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct WorkerShutdownReport {
pub jobs: usize,
pub queues: usize,
pub drained: bool,
pub in_flight: u64,
pub retry_queue_depth: u64,
pub dlq_depth: u64,
}
pub struct WorkerServer {
event_log: Arc<AnyEventLog>,
dispatcher: Dispatcher,
cron_connector: Option<harn_vm::CronConnector>,
shutdown_tx: broadcast::Sender<()>,
tasks: Vec<JoinHandle<Result<(), DispatchError>>>,
jobs: Vec<WorkerJobRegistration>,
queues: BTreeSet<String>,
drain_timeout: StdDuration,
}
impl WorkerServer {
pub fn event_log(&self) -> Arc<AnyEventLog> {
self.event_log.clone()
}
pub fn jobs(&self) -> &[WorkerJobRegistration] {
&self.jobs
}
pub async fn shutdown(mut self) -> Result<WorkerShutdownReport, DispatchError> {
let _ = self.shutdown_tx.send(());
if let Some(connector) = self.cron_connector.take() {
harn_vm::Connector::shutdown(&connector, self.drain_timeout)
.await
.map_err(|error| DispatchError::Execution(error.to_string()))?;
}
self.dispatcher.shutdown();
for task in self.tasks {
match task.await {
Ok(Ok(())) => {}
Ok(Err(error)) => return Err(error),
Err(error) if error.is_cancelled() => {}
Err(error) => {
return Err(DispatchError::Execution(format!(
"worker task join failed: {error}"
)));
}
}
}
let drain = self
.dispatcher
.drain(self.drain_timeout)
.await
.map_err(|error| DispatchError::Execution(error.to_string()))?;
Ok(WorkerShutdownReport {
jobs: self.jobs.len(),
queues: self.queues.len(),
drained: drain.drained,
in_flight: drain.in_flight,
retry_queue_depth: drain.retry_queue_depth,
dlq_depth: drain.dlq_depth,
})
}
}
#[derive(Clone, Debug)]
pub struct JobRunOutcome {
pub job: String,
pub status: DispatchStatus,
pub attempt_count: u32,
pub result: Option<serde_json::Value>,
pub error: Option<String>,
}
impl JobRunOutcome {
pub fn succeeded(&self) -> bool {
matches!(self.status, DispatchStatus::Succeeded)
}
pub fn report_json(&self) -> serde_json::Value {
match (&self.result, self.succeeded()) {
(Some(value), true) => value.clone(),
_ => serde_json::json!({
"status": self.status.as_str(),
"error": self.error.clone().unwrap_or_default(),
"attempt_count": self.attempt_count,
}),
}
}
}
struct PreparedJobRuntime {
event_log: Arc<AnyEventLog>,
vm: Vm,
jobs: Vec<PreparedJob>,
}
struct PreparedJob {
export: WorkerJobRegistration,
budget: Option<BudgetSpec>,
}
pub async fn start_worker_server(
script_path: &Path,
options: WorkerServeOptions,
) -> Result<WorkerServer, DispatchError> {
let prepared = prepare_job_runtime(script_path, |_vm| {}, None).await?;
if prepared.jobs.is_empty() {
return Err(DispatchError::Validation(format!(
"{} does not export any `@job` functions",
script_path.display()
)));
}
let budgets_by_binding: Arc<BTreeMap<String, Option<BudgetSpec>>> = Arc::new(
prepared
.jobs
.iter()
.map(|job| (job.export.binding_id.clone(), job.budget.clone()))
.collect(),
);
let jobs: Vec<WorkerJobRegistration> =
prepared.jobs.iter().map(|job| job.export.clone()).collect();
let queues: BTreeSet<String> = jobs.iter().filter_map(|job| job.queue.clone()).collect();
let dispatcher = Dispatcher::with_event_log(prepared.vm, prepared.event_log.clone());
let (shutdown_tx, _) = broadcast::channel(16);
let mut tasks = Vec::new();
tasks.push(spawn_inbox_pump(
prepared.event_log.clone(),
dispatcher.clone(),
budgets_by_binding.clone(),
shutdown_tx.subscribe(),
)?);
let has_scheduled_jobs = jobs.iter().any(|job| job.schedule.is_some());
if has_scheduled_jobs {
tasks.push(spawn_cron_pump(
prepared.event_log.clone(),
dispatcher.clone(),
shutdown_tx.subscribe(),
)?);
}
let consumer_id = options.consumer_id.unwrap_or_else(default_consumer_id);
for queue_name in &queues {
tasks.push(spawn_queue_consumer(
prepared.event_log.clone(),
dispatcher.clone(),
queue_name.clone(),
consumer_id.clone(),
options.claim_ttl,
budgets_by_binding.clone(),
shutdown_tx.subscribe(),
)?);
}
let mut cron_connector = harn_vm::CronConnector::new();
if has_scheduled_jobs {
let metrics = Arc::new(MetricsRegistry::default());
let inbox = Arc::new(
harn_vm::InboxIndex::new(prepared.event_log.clone(), metrics.clone())
.await
.map_err(|error| DispatchError::Execution(error.to_string()))?,
);
harn_vm::Connector::init(
&mut cron_connector,
harn_vm::ConnectorCtx {
event_log: prepared.event_log.clone(),
secrets: Arc::new(harn_vm::secrets::ChainSecretProvider::new(
"harn-worker",
Vec::<Arc<dyn harn_vm::secrets::SecretProvider>>::new(),
)),
inbox,
metrics,
rate_limiter: Arc::new(RateLimiterFactory::new(RateLimitConfig::default())),
},
)
.await
.map_err(|error| DispatchError::Execution(error.to_string()))?;
let cron_bindings = jobs
.iter()
.filter_map(cron_connector_binding)
.collect::<Vec<_>>();
harn_vm::Connector::activate(&cron_connector, &cron_bindings)
.await
.map_err(|error| DispatchError::Execution(error.to_string()))?;
}
Ok(WorkerServer {
event_log: prepared.event_log,
dispatcher,
cron_connector: has_scheduled_jobs.then_some(cron_connector),
shutdown_tx,
tasks,
jobs,
queues,
drain_timeout: options.drain_timeout,
})
}
async fn prepare_job_runtime(
script_path: &Path,
configure: impl FnOnce(&mut Vm),
retry_override: Option<&TriggerRetryConfig>,
) -> Result<PreparedJobRuntime, DispatchError> {
harn_vm::reset_thread_local_state();
harn_vm::clear_trigger_registry();
harn_vm::clear_dispatcher_state();
let script_path = std::fs::canonicalize(script_path).map_err(|error| {
DispatchError::Io(format!(
"failed to resolve job script {}: {error}",
script_path.display()
))
})?;
let script_path = script_path.as_path();
let catalog = ExportCatalog::from_path(script_path)?;
crate::emit_export_diagnostics(catalog.diagnostics());
validate_unique_job_names(&catalog)?;
let base_dir = script_path
.parent()
.unwrap_or_else(|| Path::new("."))
.to_path_buf();
let event_log = install_default_for_base_dir(&base_dir).map_err(|error| {
DispatchError::Io(format!(
"failed to initialize event log for {}: {error}",
base_dir.display()
))
})?;
let mut vm = Vm::new();
harn_vm::register_vm_stdlib(&mut vm);
harn_vm::register_store_builtins(&mut vm, &base_dir);
harn_vm::register_metadata_builtins(&mut vm, &base_dir);
vm.set_source_dir(&base_dir);
vm.set_harness(harn_vm::Harness::real());
configure(&mut vm);
let exports = vm
.load_module_exports(script_path)
.await
.map_err(|error| DispatchError::Execution(error.to_string()))?;
let mut jobs = Vec::new();
for function in catalog.functions.values() {
let Some(job) = function.job.clone() else {
continue;
};
let closure = exports.get(&function.name).cloned().ok_or_else(|| {
DispatchError::MissingExport(format!(
"function '{}' is not exported by {}",
function.name,
script_path.display()
))
})?;
let spec = job_binding_spec(&job, function, closure, retry_override);
let binding_id = dynamic_register(spec)
.await
.map_err(|error| DispatchError::Execution(error.to_string()))?;
let binding = resolve_live_trigger_binding(binding_id.as_str(), None)
.map_err(|error| DispatchError::Execution(error.to_string()))?;
jobs.push(PreparedJob {
export: WorkerJobRegistration {
job: job.name.clone(),
function: function.name.clone(),
binding_id: binding_id.as_str().to_string(),
binding_key: binding.binding_key(),
binding_version: binding.version,
schedule: job.schedule.clone(),
queue: job.queue.clone(),
},
budget: function.budget.clone(),
});
}
Ok(PreparedJobRuntime {
event_log,
vm,
jobs,
})
}
fn validate_unique_job_names(catalog: &ExportCatalog) -> Result<(), DispatchError> {
let mut seen = BTreeSet::new();
for function in catalog.functions.values() {
let Some(job) = function.job.as_ref() else {
continue;
};
if !seen.insert(job.name.clone()) {
return Err(DispatchError::Validation(format!(
"multiple `@job(\"{}\")` exports found in {}; job names must be unique",
job.name,
catalog.script_path.display()
)));
}
}
Ok(())
}
fn cron_connector_binding(job: &WorkerJobRegistration) -> Option<harn_vm::TriggerBinding> {
let schedule = job.schedule.as_ref()?;
let mut binding = harn_vm::TriggerBinding::new(
ProviderId::from(CRON_PROVIDER),
harn_vm::TriggerKind::from(CRON_KIND),
job.binding_id.clone(),
);
binding.config = serde_json::json!({
"schedule": schedule.cron,
"timezone": schedule.timezone.as_deref().unwrap_or("UTC"),
"retention_days": harn_vm::DEFAULT_INBOX_RETENTION_DAYS,
});
Some(binding)
}
fn spawn_cron_pump(
event_log: Arc<AnyEventLog>,
dispatcher: Dispatcher,
shutdown_rx: broadcast::Receiver<()>,
) -> Result<JoinHandle<Result<(), DispatchError>>, DispatchError> {
let topic = Topic::new(harn_vm::connectors::cron::CRON_TICK_TOPIC)
.map_err(|error| DispatchError::Execution(error.to_string()))?;
Ok(tokio::task::spawn_local(run_cron_pump(
event_log,
dispatcher,
topic,
shutdown_rx,
)))
}
async fn run_cron_pump(
event_log: Arc<AnyEventLog>,
dispatcher: Dispatcher,
topic: Topic,
mut shutdown_rx: broadcast::Receiver<()>,
) -> Result<(), DispatchError> {
let start_from = event_log
.latest(&topic)
.await
.map_err(|error| DispatchError::Execution(error.to_string()))?;
let mut stream = event_log
.clone()
.subscribe(&topic, start_from)
.await
.map_err(|error| DispatchError::Execution(error.to_string()))?;
loop {
tokio::select! {
_ = shutdown_rx.recv() => break,
received = stream.next() => {
let Some(received) = received else {
break;
};
let (_, logged) = received
.map_err(|error| DispatchError::Execution(error.to_string()))?;
if logged.kind != "trigger_event" {
continue;
}
let event: TriggerEvent = serde_json::from_value(logged.payload)
.map_err(|error| DispatchError::Execution(format!("failed to decode cron trigger event: {error}")))?;
let trigger_id = match &event.provider_payload {
ProviderPayload::Known(KnownProviderPayload::Cron(payload)) => {
payload.cron_id.clone()
}
_ => None,
};
dispatcher
.enqueue_targeted_with_headers(trigger_id, None, event, Some(&logged.headers))
.await
.map_err(|error| DispatchError::Execution(error.to_string()))?;
}
}
}
Ok(())
}
fn spawn_inbox_pump(
event_log: Arc<AnyEventLog>,
dispatcher: Dispatcher,
budgets_by_binding: Arc<BTreeMap<String, Option<BudgetSpec>>>,
shutdown_rx: broadcast::Receiver<()>,
) -> Result<JoinHandle<Result<(), DispatchError>>, DispatchError> {
let topic = Topic::new(harn_vm::TRIGGER_INBOX_ENVELOPES_TOPIC)
.map_err(|error| DispatchError::Execution(error.to_string()))?;
Ok(tokio::task::spawn_local(run_inbox_pump(
event_log,
dispatcher,
budgets_by_binding,
topic,
shutdown_rx,
)))
}
async fn run_inbox_pump(
event_log: Arc<AnyEventLog>,
dispatcher: Dispatcher,
budgets_by_binding: Arc<BTreeMap<String, Option<BudgetSpec>>>,
topic: Topic,
mut shutdown_rx: broadcast::Receiver<()>,
) -> Result<(), DispatchError> {
let start_from = event_log
.latest(&topic)
.await
.map_err(|error| DispatchError::Execution(error.to_string()))?;
let mut stream = event_log
.clone()
.subscribe(&topic, start_from)
.await
.map_err(|error| DispatchError::Execution(error.to_string()))?;
loop {
tokio::select! {
_ = shutdown_rx.recv() => break,
received = stream.next() => {
let Some(received) = received else {
break;
};
let (_, logged) = received
.map_err(|error| DispatchError::Execution(error.to_string()))?;
if logged.kind != "event_ingested" {
continue;
}
let envelope: harn_vm::triggers::dispatcher::InboxEnvelope =
serde_json::from_value(logged.payload)
.map_err(|error| DispatchError::Execution(format!("failed to decode dispatcher inbox event: {error}")))?;
let budget = budget_for_envelope(&envelope, &budgets_by_binding).cloned().flatten();
let _budget_guard = budget.as_ref().and_then(BudgetSpec::install);
dispatcher
.dispatch_inbox_envelope_with_parent_headers(envelope, &logged.headers)
.await
.map_err(|error| DispatchError::Execution(error.to_string()))?;
}
}
}
Ok(())
}
fn spawn_queue_consumer(
event_log: Arc<AnyEventLog>,
dispatcher: Dispatcher,
queue_name: String,
consumer_id: String,
claim_ttl: StdDuration,
budgets_by_binding: Arc<BTreeMap<String, Option<BudgetSpec>>>,
shutdown_rx: broadcast::Receiver<()>,
) -> Result<JoinHandle<Result<(), DispatchError>>, DispatchError> {
let topic = Topic::new(harn_vm::worker_job_topic_name(&queue_name))
.map_err(|error| DispatchError::Execution(error.to_string()))?;
Ok(tokio::task::spawn_local(run_queue_consumer(
event_log,
dispatcher,
queue_name,
consumer_id,
claim_ttl,
budgets_by_binding,
topic,
shutdown_rx,
)))
}
async fn run_queue_consumer(
event_log: Arc<AnyEventLog>,
dispatcher: Dispatcher,
queue_name: String,
consumer_id: String,
claim_ttl: StdDuration,
budgets_by_binding: Arc<BTreeMap<String, Option<BudgetSpec>>>,
topic: Topic,
mut shutdown_rx: broadcast::Receiver<()>,
) -> Result<(), DispatchError> {
let start_from = event_log
.latest(&topic)
.await
.map_err(|error| DispatchError::Execution(error.to_string()))?;
let mut stream = event_log
.clone()
.subscribe(&topic, start_from)
.await
.map_err(|error| DispatchError::Execution(error.to_string()))?;
let queue = WorkerQueue::new(event_log);
drain_queue(
&queue,
&dispatcher,
&queue_name,
&consumer_id,
claim_ttl,
&budgets_by_binding,
)
.await?;
loop {
tokio::select! {
_ = shutdown_rx.recv() => break,
received = stream.next() => {
let Some(received) = received else {
break;
};
let (_, logged) = received
.map_err(|error| DispatchError::Execution(error.to_string()))?;
if logged.kind == "trigger_dispatch" {
drain_queue(
&queue,
&dispatcher,
&queue_name,
&consumer_id,
claim_ttl,
&budgets_by_binding,
)
.await?;
}
}
}
}
Ok(())
}
async fn drain_queue(
queue: &WorkerQueue,
dispatcher: &Dispatcher,
queue_name: &str,
consumer_id: &str,
claim_ttl: StdDuration,
budgets_by_binding: &BTreeMap<String, Option<BudgetSpec>>,
) -> Result<(), DispatchError> {
loop {
let Some(claimed) = queue
.claim_next(queue_name, consumer_id, claim_ttl)
.await
.map_err(|error| {
DispatchError::Execution(format!("failed to claim worker job: {error}"))
})?
else {
break;
};
let heartbeat = start_claim_heartbeat(queue.clone(), claimed.handle.clone(), claim_ttl);
let response = match resolve_live_trigger_binding(&claimed.job.trigger_id, None) {
Ok(binding) if matches!(binding.handler, TriggerHandlerSpec::Worker { .. }) => {
WorkerQueueResponseRecord {
queue: queue_name.to_string(),
job_event_id: claimed.handle.job_event_id,
consumer_id: consumer_id.to_string(),
handled_at_ms: now_ms(),
outcome: None,
error: Some(format!(
"worker queue '{}' resolved trigger '{}' to another worker:// handler; queue consumers require a non-worker binding",
queue_name, claimed.job.trigger_id
)),
}
}
Ok(binding) => {
let budget = budgets_by_binding.get(&claimed.job.trigger_id).cloned().flatten();
let _budget_guard = budget.as_ref().and_then(BudgetSpec::install);
match dispatcher.dispatch(&binding, claimed.job.event.clone()).await {
Ok(outcome) => WorkerQueueResponseRecord {
queue: queue_name.to_string(),
job_event_id: claimed.handle.job_event_id,
consumer_id: consumer_id.to_string(),
handled_at_ms: now_ms(),
outcome: Some(outcome),
error: None,
},
Err(error) => WorkerQueueResponseRecord {
queue: queue_name.to_string(),
job_event_id: claimed.handle.job_event_id,
consumer_id: consumer_id.to_string(),
handled_at_ms: now_ms(),
outcome: None,
error: Some(error.to_string()),
},
}
}
Err(error) => WorkerQueueResponseRecord {
queue: queue_name.to_string(),
job_event_id: claimed.handle.job_event_id,
consumer_id: consumer_id.to_string(),
handled_at_ms: now_ms(),
outcome: None,
error: Some(format!(
"failed to resolve worker binding '{}': {error}",
claimed.job.trigger_id
)),
},
};
stop_claim_heartbeat(heartbeat).await;
queue
.append_response(queue_name, &response)
.await
.map_err(|error| {
DispatchError::Execution(format!("failed to append worker response: {error}"))
})?;
let should_ack = response.error.is_none()
&& response.outcome.as_ref().is_some_and(|outcome| {
matches!(
outcome.status,
DispatchStatus::Succeeded | DispatchStatus::Skipped | DispatchStatus::Dlq
)
});
if should_ack {
queue.ack_claim(&claimed.handle).await.map_err(|error| {
DispatchError::Execution(format!("failed to ack worker claim: {error}"))
})?;
}
}
Ok(())
}
fn budget_for_envelope<'a>(
envelope: &harn_vm::triggers::dispatcher::InboxEnvelope,
budgets_by_binding: &'a BTreeMap<String, Option<BudgetSpec>>,
) -> Option<&'a Option<BudgetSpec>> {
if let Some(trigger_id) = envelope.trigger_id.as_ref() {
return budgets_by_binding.get(trigger_id);
}
match &envelope.event.provider_payload {
ProviderPayload::Known(KnownProviderPayload::Cron(payload)) => payload
.cron_id
.as_ref()
.and_then(|trigger_id| budgets_by_binding.get(trigger_id)),
_ => None,
}
}
fn start_claim_heartbeat(
queue: WorkerQueue,
handle: harn_vm::WorkerQueueClaimHandle,
ttl: StdDuration,
) -> (watch::Sender<bool>, JoinHandle<()>) {
let (stop_tx, mut stop_rx) = watch::channel(false);
let interval = heartbeat_interval(ttl);
let join = tokio::task::spawn_local(async move {
loop {
tokio::select! {
changed = stop_rx.changed() => {
if changed.is_err() || *stop_rx.borrow() {
break;
}
}
_ = tokio::time::sleep(interval) => {
if queue.renew_claim(&handle, ttl).await.unwrap_or(false) {
continue;
}
break;
}
}
}
});
(stop_tx, join)
}
async fn stop_claim_heartbeat(heartbeat: (watch::Sender<bool>, JoinHandle<()>)) {
let (stop_tx, join) = heartbeat;
let _ = stop_tx.send(true);
let _ = join.await;
}
fn heartbeat_interval(ttl: StdDuration) -> StdDuration {
let millis = ttl.as_millis() as u64;
StdDuration::from_millis((millis / 2).clamp(250, 30_000))
}
fn default_consumer_id() -> String {
format!(
"harn-worker-pid{}-{}",
std::process::id(),
uuid::Uuid::new_v4()
)
}
fn now_ms() -> i64 {
harn_vm::clock_mock::now_ms()
}
#[derive(Clone, Debug, Default)]
pub struct JobRunOptions {
pub retry_override: Option<TriggerRetryConfig>,
}
impl JobRunOptions {
pub fn with_retry(mut self, retry: TriggerRetryConfig) -> Self {
self.retry_override = Some(retry);
self
}
pub fn fail_fast() -> Self {
Self {
retry_override: Some(TriggerRetryConfig::new(
1,
RetryPolicy::Linear { delay_ms: 0 },
)),
}
}
}
pub async fn run_job_once(
script_path: &Path,
job_name: &str,
request: serde_json::Value,
) -> Result<JobRunOutcome, DispatchError> {
run_job_once_with(script_path, job_name, request, |_vm| {}).await
}
pub async fn run_job_once_with(
script_path: &Path,
job_name: &str,
request: serde_json::Value,
configure: impl FnOnce(&mut Vm),
) -> Result<JobRunOutcome, DispatchError> {
run_job_once_with_options(
script_path,
job_name,
request,
JobRunOptions::default(),
configure,
)
.await
}
pub async fn run_job_once_with_options(
script_path: &Path,
job_name: &str,
request: serde_json::Value,
options: JobRunOptions,
configure: impl FnOnce(&mut Vm),
) -> Result<JobRunOutcome, DispatchError> {
let prepared =
prepare_job_runtime(script_path, configure, options.retry_override.as_ref()).await?;
let job = prepared
.jobs
.iter()
.find(|job| job.export.job == job_name)
.ok_or_else(|| {
DispatchError::MissingExport(format!(
"no `@job(\"{job_name}\")` exported by {}",
script_path.display()
))
})?;
let binding = resolve_live_trigger_binding(&job.export.binding_id, None)
.map_err(|error| DispatchError::Execution(error.to_string()))?;
let _budget_guard = job.budget.as_ref().and_then(BudgetSpec::install);
let event = job_event(&job.export.job, request)?;
let dispatcher = Dispatcher::with_event_log(prepared.vm, prepared.event_log);
let outcome = dispatcher
.dispatch(&binding, event)
.await
.map_err(|error| DispatchError::Execution(error.to_string()))?;
Ok(job_run_outcome(&job.export.job, outcome))
}
fn job_binding_spec(
job: &JobSpec,
function: &ExportedFunction,
closure: Arc<harn_vm::VmClosure>,
retry_override: Option<&TriggerRetryConfig>,
) -> TriggerBindingSpec {
let retry = match retry_override {
Some(config) => config.clone(),
None => job.retry.as_ref().map(retry_config).unwrap_or_default(),
};
let (provider, kind) = if job.schedule.is_some() {
(CRON_PROVIDER, CRON_KIND)
} else {
(JOB_PROVIDER, "job")
};
TriggerBindingSpec {
id: format!("job:{}", job.name),
source: TriggerBindingSource::Dynamic,
kind: kind.to_string(),
provider: ProviderId::from(provider),
autonomy_tier: harn_vm::AutonomyTier::ActAuto,
handler: TriggerHandlerSpec::Local {
raw: function.name.clone(),
closure,
},
dispatch_priority: WorkerQueuePriority::Normal,
when: None,
when_budget: None,
retry,
match_events: Vec::new(),
dedupe_key: None,
dedupe_retention_days: harn_vm::DEFAULT_INBOX_RETENTION_DAYS,
filter: None,
daily_cost_usd: None,
hourly_cost_usd: None,
max_autonomous_decisions_per_hour: None,
max_autonomous_decisions_per_day: None,
on_budget_exhausted: harn_vm::TriggerBudgetExhaustionStrategy::False,
max_concurrent: None,
flow_control: harn_vm::TriggerFlowControlConfig::default(),
aggregation: None,
manifest_path: None,
package_name: None,
definition_fingerprint: format!("job:{}:v1", job.name),
}
}
fn retry_config(spec: &RetrySpec) -> TriggerRetryConfig {
let policy = match spec.backoff {
RetryBackoff::Svix => RetryPolicy::Svix,
RetryBackoff::Linear => RetryPolicy::Linear { delay_ms: 1_000 },
RetryBackoff::Exponential => RetryPolicy::Exponential {
base_ms: 1_000,
cap_ms: 60_000,
},
};
TriggerRetryConfig::new(spec.max_attempts, policy)
}
fn job_event(job_name: &str, request: serde_json::Value) -> Result<TriggerEvent, DispatchError> {
Ok(TriggerEvent::new(
ProviderId::from(JOB_PROVIDER),
"job",
None,
format!("job:{job_name}:{}", uuid::Uuid::new_v4()),
None,
std::collections::BTreeMap::new(),
ProviderPayload::Known(KnownProviderPayload::Webhook(GenericWebhookPayload {
source: Some(format!("job:{job_name}")),
content_type: Some("application/json".to_string()),
raw: request,
})),
SignatureStatus::Verified,
))
}
fn job_run_outcome(job_name: &str, outcome: DispatchOutcome) -> JobRunOutcome {
JobRunOutcome {
job: job_name.to_string(),
status: outcome.status,
attempt_count: outcome.attempt_count,
result: outcome.result,
error: outcome.error,
}
}
pub async fn run_job_from_files(
script_path: &Path,
job_name: &str,
request_path: &Path,
result_out: Option<&Path>,
pretty: bool,
) -> Result<(JobRunOutcome, String), DispatchError> {
let raw = std::fs::read_to_string(request_path).map_err(|error| {
DispatchError::Io(format!(
"failed to read request {}: {error}",
request_path.display()
))
})?;
let request: serde_json::Value = serde_json::from_str(&raw).map_err(|error| {
DispatchError::Validation(format!(
"request {} is not valid JSON: {error}",
request_path.display()
))
})?;
let outcome = run_job_once(script_path, job_name, request).await?;
let report = outcome.report_json();
let rendered = if pretty {
serde_json::to_string_pretty(&report)
} else {
serde_json::to_string(&report)
}
.map_err(|error| DispatchError::Execution(format!("failed to render report JSON: {error}")))?;
if let Some(out) = result_out {
std::fs::write(out, &rendered).map_err(|error| {
DispatchError::Io(format!("failed to write report {}: {error}", out.display()))
})?;
}
Ok((outcome, rendered))
}
pub fn script_path_buf(path: &str) -> PathBuf {
PathBuf::from(path)
}
#[cfg(test)]
mod tests {
use super::*;
static ENV_LOCK: tokio::sync::Mutex<()> = tokio::sync::Mutex::const_new(());
struct ScopedEnvVar {
key: &'static str,
previous: Option<String>,
}
impl ScopedEnvVar {
fn set(key: &'static str, value: &str) -> Self {
let previous = std::env::var(key).ok();
std::env::set_var(key, value);
Self { key, previous }
}
}
impl Drop for ScopedEnvVar {
fn drop(&mut self) {
match &self.previous {
Some(value) => std::env::set_var(self.key, value),
None => std::env::remove_var(self.key),
}
}
}
async fn write_script(dir: &Path, body: &str) -> PathBuf {
let path = dir.join("worker.harn");
tokio::fs::write(&path, body).await.expect("write script");
path
}
async fn wait_for_log_event(
event_log: Arc<AnyEventLog>,
topic_name: &str,
matches: impl Fn(&harn_vm::event_log::LogEvent) -> bool,
) -> harn_vm::event_log::LogEvent {
let topic = Topic::new(topic_name).expect("test topic is valid");
let latest = event_log.latest(&topic).await.expect("latest event id");
let mut stream = event_log
.clone()
.subscribe(&topic, latest)
.await
.expect("subscribe to topic");
for (_, event) in event_log
.read_range(&topic, None, usize::MAX)
.await
.expect("read topic")
{
if matches(&event) {
return event;
}
}
tokio::time::timeout(StdDuration::from_secs(5), async {
loop {
let Some(received) = stream.next().await else {
panic!("event stream ended before matching event");
};
let (_, event) = received.expect("read event");
if matches(&event) {
return event;
}
}
})
.await
.expect("matching event")
}
async fn wait_for_attempt(
event_log: Arc<AnyEventLog>,
trigger_id: &str,
) -> harn_vm::event_log::LogEvent {
wait_for_log_event(event_log, harn_vm::TRIGGER_ATTEMPTS_TOPIC, |event| {
event.kind == "attempt_recorded"
&& event
.payload
.get("trigger_id")
.and_then(|value| value.as_str())
== Some(trigger_id)
})
.await
}
#[tokio::test(flavor = "current_thread")]
async fn one_shot_job_echoes_request_and_succeeds() {
let local = tokio::task::LocalSet::new();
local
.run_until(async {
let dir = tempfile::tempdir().expect("tempdir");
let script = write_script(
dir.path(),
r#"
import "std/triggers"
@job("scan")
pub fn scan(event: TriggerEvent) -> dict {
let req = event.provider_payload.raw
return {status: "ok", echo: req}
}
"#,
)
.await;
let request = serde_json::json!({"repo": "burin-labs/harn", "n": 7});
let outcome = run_job_once(&script, "scan", request.clone())
.await
.expect("run job");
assert_eq!(outcome.status, DispatchStatus::Succeeded);
assert_eq!(outcome.attempt_count, 1);
let result = outcome.result.expect("result");
assert_eq!(result["status"], serde_json::json!("ok"));
assert_eq!(result["echo"], request);
})
.await;
}
#[tokio::test(flavor = "current_thread")]
async fn one_shot_resolves_public_job_name_not_function_name() {
let local = tokio::task::LocalSet::new();
local
.run_until(async {
let dir = tempfile::tempdir().expect("tempdir");
let script = write_script(
dir.path(),
r#"
import "std/triggers"
@job("scan")
pub fn run_scan(event: TriggerEvent) -> dict {
return {status: "ok", echo: event.provider_payload.raw}
}
"#,
)
.await;
let outcome = run_job_once(&script, "scan", serde_json::json!({"id": 7}))
.await
.expect("run job by public name");
assert_eq!(outcome.job, "scan");
assert_eq!(outcome.status, DispatchStatus::Succeeded);
assert_eq!(
outcome.result.expect("result")["echo"],
serde_json::json!({"id": 7})
);
})
.await;
}
#[tokio::test(flavor = "current_thread")]
async fn configure_hook_registers_callable_host_builtin() {
let local = tokio::task::LocalSet::new();
local
.run_until(async {
let dir = tempfile::tempdir().expect("tempdir");
let script = write_script(
dir.path(),
r#"
import "std/triggers"
@job("scan")
pub fn scan(event: TriggerEvent) -> dict {
let req = event.provider_payload.raw
return {status: "ok", host: host_echo(req.repo)}
}
"#,
)
.await;
let request = serde_json::json!({"repo": "burin-labs/harn"});
let outcome = run_job_once_with(&script, "scan", request, |vm| {
vm.register_builtin("host_echo", |args, _out| {
let x = args.first().map(|a| a.display()).unwrap_or_default();
Ok(harn_vm::VmValue::String(arcstr::ArcStr::from(
format!("host:{x}").as_str(),
)))
});
})
.await
.expect("run job");
assert_eq!(outcome.status, DispatchStatus::Succeeded);
let result = outcome.result.expect("result");
assert_eq!(result["status"], serde_json::json!("ok"));
assert_eq!(result["host"], serde_json::json!("host:burin-labs/harn"));
})
.await;
}
#[tokio::test(flavor = "current_thread")]
async fn handler_error_retries_then_dlqs() {
let local = tokio::task::LocalSet::new();
local
.run_until(async {
let dir = tempfile::tempdir().expect("tempdir");
let script = write_script(
dir.path(),
r#"
import "std/triggers"
@job("scan")
@retry(max: 2, backoff: "linear")
pub fn scan(event: TriggerEvent) -> dict {
throw "boom"
}
"#,
)
.await;
let outcome = run_job_once(&script, "scan", serde_json::json!({}))
.await
.expect("run job returns terminal outcome");
assert_eq!(outcome.status, DispatchStatus::Dlq);
assert_eq!(outcome.attempt_count, 2);
assert!(!outcome.succeeded());
let report = outcome.report_json();
assert_eq!(report["status"], serde_json::json!("dlq"));
assert!(report["error"].as_str().is_some_and(|e| e.contains("boom")));
})
.await;
}
#[tokio::test(flavor = "current_thread")]
async fn compact_job_retry_dict_still_maps_to_dispatcher_retry() {
let local = tokio::task::LocalSet::new();
local
.run_until(async {
let dir = tempfile::tempdir().expect("tempdir");
let script = write_script(
dir.path(),
r#"
import "std/triggers"
@job("scan", retry: { max: 2, backoff: "linear" })
pub fn scan(event: TriggerEvent) -> dict {
throw "boom"
}
"#,
)
.await;
let outcome = run_job_once(&script, "scan", serde_json::json!({}))
.await
.expect("run job returns terminal outcome");
assert_eq!(outcome.status, DispatchStatus::Dlq);
assert_eq!(outcome.attempt_count, 2);
})
.await;
}
#[tokio::test(flavor = "current_thread")]
async fn fail_fast_override_runs_a_single_attempt_for_an_erroring_job() {
let local = tokio::task::LocalSet::new();
local
.run_until(async {
let dir = tempfile::tempdir().expect("tempdir");
let script = write_script(
dir.path(),
r#"
import "std/triggers"
@job("scan")
@retry(max: 7, backoff: "svix")
pub fn scan(event: TriggerEvent) -> dict {
throw "boom"
}
"#,
)
.await;
let started = std::time::Instant::now();
let outcome = run_job_once_with_options(
&script,
"scan",
serde_json::json!({}),
JobRunOptions::fail_fast(),
|_vm| {},
)
.await
.expect("run job returns terminal outcome");
let elapsed = started.elapsed();
assert_eq!(outcome.attempt_count, 1);
assert_eq!(outcome.status, DispatchStatus::Dlq);
assert!(!outcome.succeeded());
assert!(
elapsed < StdDuration::from_secs(5),
"fail-fast run should not sleep through retry backoff (took {elapsed:?})"
);
})
.await;
}
#[tokio::test(flavor = "current_thread")]
async fn retry_override_caps_attempts_below_the_job_policy() {
let local = tokio::task::LocalSet::new();
local
.run_until(async {
let dir = tempfile::tempdir().expect("tempdir");
let script = write_script(
dir.path(),
r#"
import "std/triggers"
@job("scan")
@retry(max: 5, backoff: "linear")
pub fn scan(event: TriggerEvent) -> dict {
throw "boom"
}
"#,
)
.await;
let outcome = run_job_once_with_options(
&script,
"scan",
serde_json::json!({}),
JobRunOptions::default().with_retry(TriggerRetryConfig::new(
3,
RetryPolicy::Linear { delay_ms: 0 },
)),
|_vm| {},
)
.await
.expect("run job returns terminal outcome");
assert_eq!(outcome.attempt_count, 3);
assert_eq!(outcome.status, DispatchStatus::Dlq);
})
.await;
}
#[tokio::test(flavor = "current_thread")]
async fn default_options_preserve_the_job_declared_retry_policy() {
let local = tokio::task::LocalSet::new();
local
.run_until(async {
let dir = tempfile::tempdir().expect("tempdir");
let script = write_script(
dir.path(),
r#"
import "std/triggers"
@job("scan")
@retry(max: 2, backoff: "linear")
pub fn scan(event: TriggerEvent) -> dict {
throw "boom"
}
"#,
)
.await;
let outcome = run_job_once_with_options(
&script,
"scan",
serde_json::json!({}),
JobRunOptions::default(),
|_vm| {},
)
.await
.expect("run job returns terminal outcome");
assert_eq!(outcome.attempt_count, 2);
assert_eq!(outcome.status, DispatchStatus::Dlq);
})
.await;
}
#[tokio::test(flavor = "current_thread")]
async fn worker_server_activates_scheduled_jobs() {
let local = tokio::task::LocalSet::new();
local
.run_until(async {
let _env_lock = ENV_LOCK.lock().await;
let _single_tick = ScopedEnvVar::set("HARN_TEST_CRON_SINGLE_TICK_AT", "1700000000");
let dir = tempfile::tempdir().expect("tempdir");
let script = write_script(
dir.path(),
r#"
import "std/triggers"
@job("tick")
@schedule("* * * * *", "UTC")
pub fn run_tick(event: TriggerEvent) -> dict {
return {status: "ok"}
}
"#,
)
.await;
let server = start_worker_server(
&script,
WorkerServeOptions {
drain_timeout: StdDuration::from_secs(5),
..WorkerServeOptions::default()
},
)
.await
.expect("start worker server");
assert_eq!(server.jobs().len(), 1);
assert_eq!(server.jobs()[0].job, "tick");
let event_log = server.event_log();
let attempt = wait_for_attempt(event_log, "job:tick").await;
assert_eq!(attempt.payload["outcome"], serde_json::json!("success"));
let report = server.shutdown().await.expect("shutdown worker");
assert!(report.drained);
assert_eq!(report.jobs, 1);
})
.await;
}
#[tokio::test(flavor = "current_thread")]
async fn worker_server_consumes_worker_queue_jobs() {
let local = tokio::task::LocalSet::new();
local
.run_until(async {
let dir = tempfile::tempdir().expect("tempdir");
let script = write_script(
dir.path(),
r#"
import "std/triggers"
@job("scan")
@queue("scan-jobs")
pub fn scan(event: TriggerEvent) -> dict {
return {status: "ok", echo: event.provider_payload.raw}
}
"#,
)
.await;
let server = start_worker_server(
&script,
WorkerServeOptions {
consumer_id: Some("test-worker".to_string()),
claim_ttl: StdDuration::from_secs(30),
drain_timeout: StdDuration::from_secs(5),
},
)
.await
.expect("start worker server");
let registration = server.jobs().first().expect("job registration").clone();
assert_eq!(registration.queue.as_deref(), Some("scan-jobs"));
let event_log = server.event_log();
let response_topic = harn_vm::worker_response_topic_name("scan-jobs");
let topic = Topic::new(response_topic.clone()).expect("response topic");
let latest = event_log.latest(&topic).await.expect("latest response");
let mut responses = event_log
.clone()
.subscribe(&topic, latest)
.await
.expect("subscribe responses");
let request = serde_json::json!({"repo": "burin-labs/harn"});
let event = job_event("scan", request.clone()).expect("job event");
WorkerQueue::new(event_log.clone())
.enqueue(&harn_vm::WorkerQueueJob {
queue: "scan-jobs".to_string(),
trigger_id: registration.binding_id.clone(),
binding_key: registration.binding_key.clone(),
binding_version: registration.binding_version,
event,
replay_of_event_id: None,
priority: WorkerQueuePriority::Normal,
})
.await
.expect("enqueue job");
let response = tokio::time::timeout(StdDuration::from_secs(5), async {
loop {
let Some(received) = responses.next().await else {
panic!("response stream ended");
};
let (_, event) = received.expect("response event");
if event.kind != "job_response" {
continue;
}
return serde_json::from_value::<WorkerQueueResponseRecord>(event.payload)
.expect("response record");
}
})
.await
.expect("worker response");
let outcome = response.outcome.expect("dispatch outcome");
assert_eq!(outcome.status, DispatchStatus::Succeeded);
assert_eq!(outcome.result.expect("result")["echo"], request);
assert_eq!(response.error, None);
let report = server.shutdown().await.expect("shutdown worker");
assert!(report.drained);
assert_eq!(report.queues, 1);
})
.await;
}
#[tokio::test(flavor = "current_thread")]
async fn duplicate_job_names_are_rejected() {
let local = tokio::task::LocalSet::new();
local
.run_until(async {
let dir = tempfile::tempdir().expect("tempdir");
let script = write_script(
dir.path(),
r#"
@job("scan")
pub fn scan_a() -> dict { return {} }
@job("scan")
pub fn scan_b() -> dict { return {} }
"#,
)
.await;
let error = run_job_once(&script, "scan", serde_json::json!({}))
.await
.expect_err("duplicate job name");
assert!(error.message().contains("job names must be unique"));
})
.await;
}
#[tokio::test(flavor = "current_thread")]
async fn run_from_files_writes_result_out() {
let local = tokio::task::LocalSet::new();
local
.run_until(async {
let dir = tempfile::tempdir().expect("tempdir");
let script = write_script(
dir.path(),
r#"
import "std/triggers"
@job("scan")
pub fn scan(event: TriggerEvent) -> dict {
return {status: "ok", echo: event.provider_payload.raw}
}
"#,
)
.await;
let request_path = dir.path().join("req.json");
tokio::fs::write(&request_path, r#"{"k": "v"}"#)
.await
.expect("write request");
let out_path = dir.path().join("out.json");
let (outcome, rendered) =
run_job_from_files(&script, "scan", &request_path, Some(&out_path), false)
.await
.expect("run job from files");
assert!(outcome.succeeded());
let written = tokio::fs::read_to_string(&out_path)
.await
.expect("read out");
assert_eq!(written, rendered);
let parsed: serde_json::Value =
serde_json::from_str(&written).expect("parse report");
assert_eq!(parsed["echo"], serde_json::json!({"k": "v"}));
})
.await;
}
#[tokio::test(flavor = "current_thread")]
async fn missing_job_attribute_is_an_error() {
let local = tokio::task::LocalSet::new();
local
.run_until(async {
let dir = tempfile::tempdir().expect("tempdir");
let script = write_script(
dir.path(),
r"
pub fn scan(req: dict) -> dict { return req }
",
)
.await;
let error = run_job_once(&script, "scan", serde_json::json!({}))
.await
.expect_err("not a job");
assert!(error.message().contains("no `@job(\"scan\")`"));
})
.await;
}
}