use super::*;
use boatramp_core::project::ProjectRef;
#[cfg(feature = "handlers")]
const SCHEDULER_TICK: Duration = Duration::from_millis(500);
#[cfg(feature = "handlers")]
pub(super) const CONSUMER_LEASE: Duration = Duration::from_secs(30);
#[cfg(feature = "handlers")]
pub(super) const CONSUMER_MAX_ATTEMPTS: u32 = 5;
#[cfg(feature = "handlers")]
pub(super) const CONSUMER_BATCH: usize = 16;
#[cfg(feature = "handlers")]
fn site_limits(
site_handlers: &boatramp_core::config::HandlersSiteConfig,
) -> boatramp_handlers::Limits {
let mut limits = boatramp_handlers::Limits::default();
if let Some(mb) = site_handlers.max_memory_mb {
limits.memory_bytes = (mb as usize).saturating_mul(1024 * 1024);
}
if let Some(ms) = site_handlers.max_timeout_ms {
limits.timeout_ms = ms as u64;
}
limits
}
#[cfg(feature = "handlers")]
#[derive(Clone, Copy)]
pub(super) struct CronNow {
pub(super) minute: u32,
pub(super) hour: u32,
pub(super) dom: u32,
pub(super) month: u32,
pub(super) dow: u32,
pub(super) minute_stamp: i64,
}
#[cfg(feature = "handlers")]
impl CronNow {
fn now() -> Self {
use chrono::{Datelike, Timelike, Utc};
let t = Utc::now();
Self {
minute: t.minute(),
hour: t.hour(),
dom: t.day(),
month: t.month(),
dow: t.weekday().num_days_from_sunday(),
minute_stamp: t.timestamp().div_euclid(60),
}
}
}
#[cfg(feature = "handlers")]
pub(super) struct CronEntry {
last_minute: i64,
pub(super) running: Arc<std::sync::atomic::AtomicBool>,
}
impl HandlerRuntime {
#[cfg(feature = "handlers")]
pub fn spawn_scheduler(&self, deploy: DeployStore) -> Option<tokio::task::JoinHandle<()>> {
let inner = self.inner.clone()?;
Some(tokio::spawn(async move {
let mut wasm_cache: std::collections::HashMap<String, Vec<u8>> =
std::collections::HashMap::new();
let mut cron_state: std::collections::HashMap<String, CronEntry> =
std::collections::HashMap::new();
let mut blob_watchers: std::collections::HashMap<String, tokio::task::JoinHandle<()>> =
std::collections::HashMap::new();
let mut interval = tokio::time::interval(SCHEDULER_TICK);
loop {
interval.tick().await;
if let Err(err) = run_scheduler_tick(
&inner,
&deploy,
&mut wasm_cache,
&mut cron_state,
CronNow::now(),
)
.await
{
tracing::warn!(%err, "scheduler tick failed");
}
reconcile_blob_watchers(&inner, &deploy, &mut blob_watchers).await;
}
}))
}
}
#[cfg(feature = "handlers")]
async fn reconcile_blob_watchers(
inner: &Arc<HandlerRuntimeInner>,
deploy: &DeployStore,
watchers: &mut std::collections::HashMap<String, tokio::task::JoinHandle<()>>,
) {
use boatramp_core::function::TriggerKind;
if inner.cron_leader_gate.get().is_some_and(|gate| !gate()) {
for (_, handle) in watchers.drain() {
handle.abort();
}
return;
}
let projects = deploy.discover_projects().await.unwrap_or_default();
let mut desired = std::collections::HashSet::new();
for project_name in projects {
let project = ProjectRef::new(&project_name);
let functions = match deploy.list_stored_functions(project).await {
Ok(f) => f,
Err(_) => continue,
};
for function in functions {
let triggers = deploy
.list_triggers(project, &function.name)
.await
.unwrap_or_default();
for trigger in triggers {
let TriggerKind::Blob { prefix } = &trigger.kind else {
continue;
};
let watch_id = format!("{project_name}|{}|{}", function.name, trigger.id);
desired.insert(watch_id.clone());
if let std::collections::hash_map::Entry::Vacant(slot) = watchers.entry(watch_id) {
if let Some(handle) = spawn_blob_watcher(
inner.clone(),
deploy.clone(),
project_name.clone(),
function.clone(),
prefix,
)
.await
{
slot.insert(handle);
}
}
}
}
}
watchers.retain(|id, handle| {
if desired.contains(id) {
true
} else {
handle.abort();
false
}
});
}
#[cfg(feature = "handlers")]
pub(super) async fn spawn_blob_watcher(
inner: Arc<HandlerRuntimeInner>,
deploy: DeployStore,
project: String,
function: boatramp_core::function::Function,
prefix: &str,
) -> Option<tokio::task::JoinHandle<()>> {
let project_ref = ProjectRef::new(&project);
let storage_prefix = blob_storage_prefix(project_ref, &function.name, prefix);
let mut stream = match inner.storage.watch(&storage_prefix).await {
Ok(Some(stream)) => stream,
Ok(None) => return None,
Err(err) => {
tracing::warn!(function = %function.name, %err, "starting blob watch failed");
return None;
}
};
let namespace = format!(
"hblob/{}/",
project_ref.qualified(&format!("fn/{}", function.name))
);
Some(tokio::spawn(async move {
use futures::StreamExt;
while let Some(change) = stream.next().await {
enqueue_blob_invocation(
&deploy,
ProjectRef::new(&project),
&function,
&change,
&namespace,
)
.await;
}
}))
}
#[cfg(feature = "handlers")]
async fn enqueue_blob_invocation(
deploy: &DeployStore,
project: ProjectRef<'_>,
function: &boatramp_core::function::Function,
change: &boatramp_core::BlobChange,
namespace: &str,
) {
use boatramp_core::BlobChangeKind;
let key = change.key.strip_prefix(namespace).unwrap_or(&change.key);
let kind = match change.kind {
BlobChangeKind::Created => "created",
BlobChangeKind::Modified => "modified",
BlobChangeKind::Removed => "removed",
};
let body = serde_json::json!({ "key": key, "kind": kind });
let payload = serde_json::to_vec(&body).unwrap_or_default();
let now = now_unix();
let inv = boatramp_core::function::Invocation {
id: new_invocation_id(),
function: function.name.clone(),
version: function.active.clone(),
mode: boatramp_core::function::InvokeMode::Async,
status: boatramp_core::function::InvocationStatus::Queued,
idempotency_key: None,
attempts: 0,
request_b64: (!payload.is_empty()).then(|| b64_encode(&payload)),
request_content_type: Some("application/json".to_string()),
result: None,
created: now,
updated: now,
};
if let Err(err) = deploy.put_invocation(project, &inv).await {
tracing::warn!(function = %function.name, %err, "enqueuing blob invocation failed");
}
}
#[cfg(feature = "handlers")]
pub(super) async fn run_scheduler_tick(
inner: &Arc<HandlerRuntimeInner>,
deploy: &DeployStore,
wasm_cache: &mut std::collections::HashMap<String, Vec<u8>>,
cron_state: &mut std::collections::HashMap<String, CronEntry>,
now: CronNow,
) -> Result<(usize, Vec<tokio::task::JoinHandle<()>>), DeployError> {
use std::sync::atomic::Ordering;
let mut acked = 0;
let mut cron_handles = Vec::new();
let projects = deploy.discover_projects().await?;
for project_name in &projects {
let project = ProjectRef::new(project_name);
for site in deploy.list_sites(project).await? {
let Some(site_config) = deploy.get_site_config(project, &site).await? else {
continue;
};
let Some(site_handlers) = site_config.handlers.as_ref().filter(|h| h.enabled) else {
continue;
};
let mut active: Vec<(String, String)> = Vec::new();
if let Some(id) = deploy.current_id(project, &site).await? {
active.push((id, site.clone()));
}
for alias in &site_handlers.background_aliases {
if let Some(id) = deploy.get_alias(project, &site, alias).await? {
active.push((id, format!("{site}/{alias}")));
}
}
for (deploy_id, scope) in active {
let Some(manifest) = deploy.get_manifest(&deploy_id).await? else {
continue;
};
if let Some(messaging) = inner.messaging.clone() {
for consumer in &manifest.config.consumers {
let Some(entry) = manifest.files.get(&consumer.component) else {
tracing::warn!(site, component = %consumer.component, "consumer component missing");
continue;
};
if !wasm_cache.contains_key(&entry.hash) {
match read_blob_bytes(deploy, &entry.hash).await {
Ok(bytes) => {
wasm_cache.insert(entry.hash.clone(), bytes);
}
Err(err) => {
tracing::warn!(site, %err, "reading consumer component failed");
continue;
}
}
}
let wasm = &wasm_cache[&entry.hash];
let bindings = build_bindings(
inner,
project,
&site,
&scope,
None,
&consumer.imports,
site_handlers,
&std::collections::BTreeMap::new(),
&[],
0,
)
.await;
acked += dispatch_consumer_batch(
&inner.engine,
messaging.as_ref(),
&inner.metrics,
&site,
&format!("{scope}/{}", consumer.topic),
&format!("{scope}/"),
&entry.hash,
wasm,
&bindings,
site_limits(site_handlers),
CONSUMER_LEASE,
CONSUMER_MAX_ATTEMPTS,
CONSUMER_BATCH,
)
.await;
}
}
let cron_enabled = inner.cron_leader_gate.get().is_none_or(|gate| gate());
for (idx, cron) in manifest.config.crons.iter().enumerate() {
if !cron_enabled {
break;
}
let Ok(schedule) = boatramp_core::cron::CronSchedule::parse(&cron.schedule)
else {
continue;
};
if !schedule.fires_at(now.minute, now.hour, now.dom, now.month, now.dow) {
continue;
}
let key = format!("{project_name}|{scope}|cron|{idx}");
let entry = cron_state.entry(key).or_insert_with(|| CronEntry {
last_minute: -1,
running: Arc::new(std::sync::atomic::AtomicBool::new(false)),
});
if entry.last_minute == now.minute_stamp {
continue; }
if matches!(cron.overlap, boatramp_core::config::Overlap::Skip)
&& entry.running.load(Ordering::Acquire)
{
tracing::info!(site, route = %cron.route, "cron skipped (previous run still in flight)");
continue;
}
entry.last_minute = now.minute_stamp;
let running = entry.running.clone();
running.store(true, Ordering::Release);
let (inner, deploy, manifest, project_owned, site, scope, site_handlers, cron) = (
inner.clone(),
deploy.clone(),
manifest.clone(),
project_name.clone(),
site.clone(),
scope.clone(),
site_handlers.clone(),
cron.clone(),
);
cron_handles.push(tokio::spawn(async move {
fire_cron(
&inner,
&deploy,
ProjectRef::new(&project_owned),
&manifest,
&site,
&scope,
&site_handlers,
&cron,
)
.await;
running.store(false, Ordering::Release);
}));
}
}
}
}
let invoke_enabled = inner.cron_leader_gate.get().is_none_or(|gate| gate());
if invoke_enabled {
for project_name in &projects {
let project = ProjectRef::new(project_name);
for function in deploy.list_stored_functions(project).await? {
dispatch_function_triggers(inner, deploy, project, &function, &now).await;
drain_function_invocations(inner, deploy, project, &function).await;
}
for workflow in deploy.list_workflows(project).await? {
drain_workflow_runs(inner, deploy, project, &workflow).await;
}
}
}
Ok((acked, cron_handles))
}
#[cfg(feature = "handlers")]
#[allow(clippy::too_many_arguments)]
async fn fire_cron(
inner: &HandlerRuntimeInner,
deploy: &DeployStore,
project: ProjectRef<'_>,
manifest: &Manifest,
site: &str,
scope: &str,
site_handlers: &boatramp_core::config::HandlersSiteConfig,
cron: &boatramp_core::config::CronConfig,
) {
let Some(handler) = route::match_handler(&manifest.config.handlers, "GET", &cron.route) else {
tracing::warn!(site, route = %cron.route, "cron route matches no GET handler");
return;
};
let Some(entry) = manifest.files.get(&handler.component) else {
return;
};
let wasm = match read_blob_bytes(deploy, &entry.hash).await {
Ok(wasm) => wasm,
Err(err) => {
tracing::warn!(site, %err, "reading cron handler component failed");
return;
}
};
let bindings = build_bindings(
inner,
project,
site,
scope,
None,
&handler.imports,
site_handlers,
&handler.env,
&handler.invoke_targets,
0,
)
.await;
let limits = effective_limits(site_handlers, handler);
let request = match axum::http::Request::builder()
.method("GET")
.uri(format!("http://localhost{}", cron.route))
.header("x-boatramp-trigger", "cron")
.body(boatramp_handlers::empty_body())
{
Ok(request) => request,
Err(_) => return,
};
let start = std::time::Instant::now();
let result = inner
.engine
.serve_with_limits(&entry.hash, &wasm, request, bindings, limits)
.await;
inner.metrics.observe(
site,
metrics::Trigger::Cron,
&cron.route,
&entry.hash,
metrics::Outcome::from_result(&result),
start.elapsed(),
);
match result {
Ok(response) => {
let _ = http_body_util::BodyExt::collect(response.into_body()).await;
tracing::info!(site, route = %cron.route, "cron fired");
}
Err(err) => tracing::warn!(site, route = %cron.route, %err, "cron invocation failed"),
}
}
#[cfg(feature = "handlers")]
pub(super) fn handler_unavailable() -> Response {
(StatusCode::SERVICE_UNAVAILABLE, "handler unavailable\n").into_response()
}
#[cfg(feature = "handlers")]
pub(super) fn effective_limits(
site_handlers: &boatramp_core::config::HandlersSiteConfig,
handler: &boatramp_core::config::HandlerConfig,
) -> boatramp_handlers::Limits {
let mut limits = boatramp_handlers::Limits::default();
let handler_limits = handler.limits.as_ref();
if let Some(mb) = [
site_handlers.max_memory_mb,
handler_limits.and_then(|l| l.memory_mb),
]
.into_iter()
.flatten()
.min()
{
limits.memory_bytes = (mb as usize).saturating_mul(1024 * 1024);
}
if let Some(ms) = [
site_handlers.max_timeout_ms,
handler_limits.and_then(|l| l.timeout_ms),
]
.into_iter()
.flatten()
.min()
{
limits.timeout_ms = ms as u64;
}
limits.fuel = [site_handlers.max_fuel, handler_limits.and_then(|l| l.fuel)]
.into_iter()
.flatten()
.min();
limits
}
#[cfg(feature = "handlers")]
pub(super) fn acquire_site_permit(
inner: &HandlerRuntimeInner,
site: &str,
site_handlers: &boatramp_core::config::HandlersSiteConfig,
) -> Result<Option<tokio::sync::OwnedSemaphorePermit>, ()> {
let Some(max) = site_handlers.max_concurrency else {
return Ok(None);
};
let semaphore = {
let mut map = inner.site_semaphores.lock().unwrap();
map.entry(site.to_string())
.or_insert_with(|| Arc::new(tokio::sync::Semaphore::new(max as usize)))
.clone()
};
semaphore.try_acquire_owned().map(Some).map_err(|_| ())
}
#[cfg(feature = "handlers")]
pub(super) fn handler_error_response(err: &boatramp_handlers::HandlerError) -> Response {
use boatramp_handlers::HandlerError;
let (status, body) = match err {
HandlerError::Timeout => (StatusCode::GATEWAY_TIMEOUT, "handler timed out\n"),
HandlerError::OutOfFuel => (
StatusCode::GATEWAY_TIMEOUT,
"handler exhausted its CPU budget\n",
),
HandlerError::Overloaded => (
StatusCode::SERVICE_UNAVAILABLE,
"handler engine at capacity\n",
),
HandlerError::Compile(_) => (
StatusCode::INTERNAL_SERVER_ERROR,
"handler failed to compile\n",
),
HandlerError::Trap(_) | HandlerError::NoResponse | HandlerError::Internal(_) => {
(StatusCode::INTERNAL_SERVER_ERROR, "handler error\n")
}
};
(status, body).into_response()
}