feat: dedicated nativets (#8021)

* feat: dedicated nativets

* review nits

* prewarm isolates

* ref

* chore: update ee-repo-ref to 5f8105b808f3f0186fdf5132d2ee602d8a14aa17

This commit updates the EE repository reference after PR #424 was merged in windmill-ee-private.

Previous ee-repo-ref: b7906acabb8ce359230bbd3e30dbb3bba4c42adb

New ee-repo-ref: 5f8105b808f3f0186fdf5132d2ee602d8a14aa17

Automated by sync-ee-ref workflow.

---------

Co-authored-by: windmill-internal-app[bot] <windmill-internal-app[bot]@users.noreply.github.com>
This commit is contained in:
hugocasa
2026-02-20 17:28:40 +01:00
committed by GitHub
parent d80e4a469a
commit 37f29d7b59
12 changed files with 1008 additions and 180 deletions

1
backend/Cargo.lock generated
View File

@@ -15778,6 +15778,7 @@ dependencies = [
"windmill-indexer",
"windmill-object-store",
"windmill-operator",
"windmill-parser-ts",
"windmill-queue",
"windmill-runtime-nativets",
"windmill-test-utils",

View File

@@ -254,6 +254,7 @@ axum.workspace = true
serde.workspace = true
windmill-api-client.workspace = true
tempfile.workspace = true
windmill-parser-ts.workspace = true
rumqttc.workspace = true
rdkafka.workspace = true
async-nats.workspace = true

View File

@@ -1 +1 @@
d842747738a2f10fc2fd0cd61f536efffcb45e41
5f8105b808f3f0186fdf5132d2ee602d8a14aa17

View File

@@ -0,0 +1,275 @@
/*
* Tests for the PrewarmedIsolate used by nativets dedicated workers.
*
* Run with:
* cargo test -p windmill --features "deno_core" --test nativets_dedicated -- --nocapture
*/
#[cfg(feature = "deno_core")]
mod prewarmed_isolate_tests {
use std::process::Command;
use windmill_runtime_nativets::{NativeAnnotation, PrewarmedIsolate};
use windmill_worker::{build_loader, LoaderMode, BUN_PATH};
fn default_annotation() -> NativeAnnotation {
NativeAnnotation { useragent: None, proxy: None }
}
/// Bundle a TypeScript script into JS suitable for `PrewarmedIsolate`.
///
/// Returns `(ts_source, js_bundle, arg_names)`.
async fn bundle_script(script: &str) -> (String, String, Vec<String>) {
let temp_dir = tempfile::tempdir().unwrap();
let dir = temp_dir.path();
let dir_str = dir.to_str().unwrap();
std::fs::write(dir.join("main.ts"), script).unwrap();
build_loader(
dir_str,
"http://localhost:8000",
"test_token",
"test-workspace",
"f/test/script",
LoaderMode::BrowserBundle,
)
.await
.expect("build_loader failed");
let output = Command::new(BUN_PATH.as_str())
.args(["run", dir.join("node_builder.ts").to_str().unwrap()])
.current_dir(dir)
.output()
.expect("Failed to run bun build");
if !output.status.success() {
panic!(
"Bun build failed: {}",
String::from_utf8_lossy(&output.stderr)
);
}
let ts = std::fs::read_to_string(dir.join("main.ts")).unwrap();
let js = std::fs::read_to_string(dir.join("main.js")).unwrap();
let parsed = windmill_parser_ts::parse_deno_signature(&ts, true, false, None)
.expect("failed to parse signature");
let arg_names: Vec<String> = parsed.args.into_iter().map(|a| a.name).collect();
(ts, js, arg_names)
}
const TEST_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(30);
async fn run_prewarmed_test(
script: &str,
jobs: Vec<serde_json::Value>,
) -> Vec<Result<serde_json::Value, String>> {
tokio::time::timeout(TEST_TIMEOUT, run_prewarmed_test_inner(script, jobs))
.await
.expect("test timed out after 30s")
}
async fn run_prewarmed_test_inner(
script: &str,
jobs: Vec<serde_json::Value>,
) -> Vec<Result<serde_json::Value, String>> {
windmill_runtime_nativets::setup_deno_runtime().expect("V8 init failed");
let (_ts, js, arg_names) = bundle_script(script).await;
let ann = default_annotation();
let mut results = Vec::new();
for job_args in &jobs {
let mut isolate =
PrewarmedIsolate::spawn("".to_string(), js.clone(), ann.clone(), arg_names.clone());
isolate.wait_ready().await.expect("isolate failed to warm");
let args = serde_json::to_string(job_args).unwrap();
let executing = isolate.start_execution(args);
let prewarmed_result = executing.wait().await.expect("isolate execution failed");
match prewarmed_result.result {
Ok(raw) => {
let value: serde_json::Value =
serde_json::from_str(raw.get()).unwrap_or(serde_json::Value::Null);
results.push(Ok(value));
}
Err(e) => {
results.push(Err(e));
}
}
}
results
}
#[tokio::test(flavor = "multi_thread")]
async fn test_prewarmed_simple() {
let script = r#"
export function main(x: number, y: number): number {
return x + y;
}
"#;
let results = run_prewarmed_test(script, vec![serde_json::json!({"x": 2, "y": 3})]).await;
assert_eq!(results.len(), 1);
assert_eq!(results[0], Ok(serde_json::json!(5)));
}
#[tokio::test(flavor = "multi_thread")]
async fn test_prewarmed_isolation() {
let script = r#"
let counter = 0;
export function main(): number {
counter++;
return counter;
}
"#;
let results =
run_prewarmed_test(script, vec![serde_json::json!({}), serde_json::json!({})]).await;
assert_eq!(results.len(), 2);
// Each job gets a fresh isolate, so counter should be 1 both times
assert_eq!(results[0], Ok(serde_json::json!(1)));
assert_eq!(results[1], Ok(serde_json::json!(1)));
}
#[tokio::test(flavor = "multi_thread")]
async fn test_prewarmed_error() {
let script = r#"
export function main(msg: string): never {
throw new Error(msg);
}
"#;
let results =
run_prewarmed_test(script, vec![serde_json::json!({"msg": "test error"})]).await;
assert_eq!(results.len(), 1);
assert!(results[0].is_err());
assert!(
results[0].as_ref().unwrap_err().contains("test error"),
"Error should contain 'test error', got: {}",
results[0].as_ref().unwrap_err()
);
}
#[tokio::test(flavor = "multi_thread")]
async fn test_prewarmed_async() {
let script = r#"
export async function main(x: number): Promise<number> {
const val = await Promise.resolve(x * 10);
return val + 1;
}
"#;
let results = run_prewarmed_test(script, vec![serde_json::json!({"x": 7})]).await;
assert_eq!(results.len(), 1);
assert_eq!(results[0], Ok(serde_json::json!(71)));
}
#[tokio::test(flavor = "multi_thread")]
async fn test_prewarmed_pipeline() {
windmill_runtime_nativets::setup_deno_runtime().expect("V8 init failed");
let script = r#"
export function main(n: number): number {
return n * 2;
}
"#;
let (_ts, js, arg_names) = bundle_script(script).await;
let ann = default_annotation();
// Pre-warm first isolate
let mut warm =
PrewarmedIsolate::spawn("".to_string(), js.clone(), ann.clone(), arg_names.clone());
warm.wait_ready()
.await
.expect("first isolate failed to warm");
let mut results = Vec::new();
for i in 1..=3 {
let args = serde_json::to_string(&serde_json::json!({"n": i})).unwrap();
let executing = warm.start_execution(args);
// Pipeline: start pre-warming next isolate while current one runs
warm =
PrewarmedIsolate::spawn("".to_string(), js.clone(), ann.clone(), arg_names.clone());
let prewarmed_result = executing.wait().await.expect("isolate execution failed");
match prewarmed_result.result {
Ok(raw) => {
let value: serde_json::Value =
serde_json::from_str(raw.get()).unwrap_or(serde_json::Value::Null);
results.push(value);
}
Err(e) => panic!("unexpected error: {e}"),
}
warm.wait_ready()
.await
.expect("next isolate failed to warm");
}
assert_eq!(
results,
vec![
serde_json::json!(2),
serde_json::json!(4),
serde_json::json!(6),
]
);
}
#[tokio::test(flavor = "multi_thread")]
async fn test_prewarmed_complex_return() {
let script = r#"
export function main(name: string, items: number[]): any {
return {
greeting: `hello ${name}`,
sum: items.reduce((a, b) => a + b, 0),
items: items.map(x => x * 2),
};
}
"#;
let results = run_prewarmed_test(
script,
vec![serde_json::json!({"name": "world", "items": [1, 2, 3]})],
)
.await;
assert_eq!(results.len(), 1);
assert_eq!(
results[0],
Ok(serde_json::json!({
"greeting": "hello world",
"sum": 6,
"items": [2, 4, 6],
}))
);
}
#[tokio::test(flavor = "multi_thread")]
async fn test_prewarmed_null_undefined() {
let script = r#"
export function main(returnNull: boolean): any {
if (returnNull) {
return null;
}
return undefined;
}
"#;
let results = run_prewarmed_test(
script,
vec![
serde_json::json!({"returnNull": true}),
serde_json::json!({"returnNull": false}),
],
)
.await;
assert_eq!(results.len(), 2);
assert_eq!(results[0], Ok(serde_json::Value::Null));
assert_eq!(results[1], Ok(serde_json::Value::Null));
}
}

View File

@@ -0,0 +1,155 @@
use std::collections::HashMap;
use serde_json::value::RawValue;
use crate::{
create_nativets_runtime, execute_main, load_client_module, load_user_module, CreatedRuntime,
ExecuteError, MainArgs, NativeAnnotation,
};
pub struct PrewarmedResult {
pub result: Result<Box<RawValue>, String>,
pub logs: String,
}
pub struct ExecutingIsolate {
result_rx: tokio::sync::oneshot::Receiver<PrewarmedResult>,
handle: tokio::task::JoinHandle<anyhow::Result<()>>,
}
impl ExecutingIsolate {
pub async fn wait(self) -> anyhow::Result<PrewarmedResult> {
let result = self
.result_rx
.await
.map_err(|_| anyhow::anyhow!("isolate result channel closed"))?;
self.handle
.await
.map_err(|e| anyhow::anyhow!("isolate thread panicked: {e}"))??;
Ok(result)
}
}
pub struct PrewarmedIsolate {
args_tx: Option<tokio::sync::oneshot::Sender<String>>,
result_rx: Option<tokio::sync::oneshot::Receiver<PrewarmedResult>>,
ready_rx: Option<tokio::sync::oneshot::Receiver<()>>,
handle: Option<tokio::task::JoinHandle<anyhow::Result<()>>>,
}
/// Parse a JSON args object and reorder into positional args matching `arg_names`.
fn args_to_positional(args_json: &str, arg_names: &[String]) -> Vec<Option<Box<RawValue>>> {
let map: HashMap<String, Box<RawValue>> = serde_json::from_str(args_json).unwrap_or_default();
arg_names
.iter()
.map(|name| map.get(name).cloned())
.collect()
}
impl PrewarmedIsolate {
/// Spawn a new isolate on a blocking thread.
///
/// The isolate loads `env_code` + WINDMILL_CLIENT as `windmill.ts`,
/// then loads `js_code` as `eval.ts`, and waits for args to execute.
pub fn spawn(
env_code: String,
js_code: String,
ann: NativeAnnotation,
arg_names: Vec<String>,
) -> Self {
let (args_tx, args_rx) = tokio::sync::oneshot::channel::<String>();
let (result_tx, result_rx) = tokio::sync::oneshot::channel::<PrewarmedResult>();
let (ready_tx, ready_rx) = tokio::sync::oneshot::channel::<()>();
let handle = tokio::task::spawn_blocking(move || {
let CreatedRuntime { mut js_runtime, log_receiver, mut memory_limit_rx } =
create_nativets_runtime(ann, vec![])?;
let runtime = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()?;
runtime.block_on(async {
load_client_module(&mut js_runtime, &env_code).await?;
load_user_module(&mut js_runtime, format!("{env_code}\n{js_code}")).await?;
let _ = ready_tx.send(());
let args_json = match args_rx.await {
Ok(a) => a,
Err(_) => return Ok(()),
};
let positional = args_to_positional(&args_json, &arg_names);
{
let op_state = js_runtime.op_state();
let mut op_state = op_state.borrow_mut();
op_state.put(MainArgs { args: positional });
}
let log_handle = tokio::spawn(async move {
let mut log_receiver = log_receiver;
let mut logs = String::new();
while let Some(log) = log_receiver.recv().await {
logs.push_str(&log);
logs.push('\n');
}
logs
});
let exec_result = tokio::select! {
r = execute_main(&mut js_runtime, None, false, None) => r,
_ = memory_limit_rx.recv() => {
Err(ExecuteError::Script("Memory limit reached, killing isolate".to_string()))
}
};
let result = match exec_result {
Ok(raw) => Ok(raw),
Err(ExecuteError::Script(msg)) => Err(msg),
Err(ExecuteError::Js { message, stack, .. }) => {
let msg = message.unwrap_or_default();
let err = match stack {
Some(s) => format!("{msg}\n{s}"),
None => msg,
};
Err(err)
}
};
drop(js_runtime);
let logs = log_handle.await.unwrap_or_default();
let _ = result_tx.send(PrewarmedResult { result, logs });
Ok(())
})
});
PrewarmedIsolate {
args_tx: Some(args_tx),
result_rx: Some(result_rx),
ready_rx: Some(ready_rx),
handle: Some(handle),
}
}
/// Wait for the isolate to finish loading modules.
pub async fn wait_ready(&mut self) -> anyhow::Result<()> {
if let Some(rx) = self.ready_rx.take() {
rx.await
.map_err(|_| anyhow::anyhow!("isolate failed during pre-warm"))?;
}
Ok(())
}
/// Send args and start execution. Returns an `ExecutingIsolate` that
/// can be awaited independently, allowing the caller to pre-warm
/// the next isolate in parallel.
pub fn start_execution(mut self, args: String) -> ExecutingIsolate {
let args_tx = self.args_tx.take().expect("start_execution called twice");
let _ = args_tx.send(args);
ExecutingIsolate {
result_rx: self.result_rx.take().expect("result_rx missing"),
handle: self.handle.take().expect("handle missing"),
}
}
}

View File

@@ -12,6 +12,9 @@
//! TypeScript scripts via the nativets runtime. By isolating this here,
//! deno_core compilation no longer blocks windmill-worker or windmill-api.
mod dedicated;
pub use dedicated::{ExecutingIsolate, PrewarmedIsolate, PrewarmedResult};
use std::{
borrow::Cow,
cell::RefCell,
@@ -112,14 +115,15 @@ impl NetPermissions for PermissionsContainer {
// ── Types ────────────────────────────────────────────────────────────
struct MainArgs {
args: Vec<Option<Box<RawValue>>>,
pub(crate) struct MainArgs {
pub(crate) args: Vec<Option<Box<RawValue>>>,
}
struct LogString {
pub s: mpsc::UnboundedSender<String>,
}
#[derive(Clone)]
pub struct NativeAnnotation {
pub useragent: Option<String>,
pub proxy: Option<(String, Option<(String, String)>)>,
@@ -145,7 +149,7 @@ impl Drop for IsolateDropGuard {
static RUNTIME_SNAPSHOT: &[u8] = include_bytes!(concat!(env!("OUT_DIR"), "/FETCH_SNAPSHOT.bin"));
const WINDMILL_CLIENT: &str = include_str!("./windmill-client.js");
pub(crate) const WINDMILL_CLIENT: &str = include_str!("./windmill-client.js");
const ERROR_DIR: &str = const_format::concatcp!(TMP_DIR, "/native_errors");
@@ -177,7 +181,10 @@ pub fn setup_deno_runtime() -> anyhow::Result<()> {
.collect::<Vec<_>>();
if !unrecognized_v8_flags.is_empty() {
init_err = Some(format!("Unrecognized V8 flags: {:?}", unrecognized_v8_flags));
init_err = Some(format!(
"Unrecognized V8 flags: {:?}",
unrecognized_v8_flags
));
}
// Use an unprotected platform that doesn't enforce thread-isolated allocations
@@ -349,6 +356,140 @@ fn op_log(op_state: Rc<RefCell<OpState>>, #[string] log: &str) {
}
}
// ── Shared V8 runtime creation ───────────────────────────────────────
pub(crate) struct CreatedRuntime {
pub(crate) js_runtime: JsRuntime,
pub(crate) log_receiver: mpsc::UnboundedReceiver<String>,
pub(crate) memory_limit_rx: mpsc::UnboundedReceiver<()>,
}
/// Create a JsRuntime with the standard nativets extensions, heap limit
/// callback, and log channel. Must be called on a blocking thread (not
/// on the async tokio runtime) because V8 isolate creation is
/// synchronous and potentially heavy.
pub(crate) fn create_nativets_runtime(
ann: NativeAnnotation,
initial_args: Vec<Option<Box<RawValue>>>,
) -> anyhow::Result<CreatedRuntime> {
let ops = vec![op_get_static_args(), op_log()];
let ext = Extension { name: "windmill", ops: ops.into(), ..Default::default() };
let fetch_options = deno_fetch::Options {
root_cert_store_provider: None,
user_agent: ann.useragent.unwrap_or_else(|| "windmill/beta".to_string()),
proxy: ann.proxy.map(|x| deno_tls::Proxy {
url: x.0,
basic_auth: x
.1
.map(|(username, password)| deno_tls::BasicAuth { username, password }),
}),
..Default::default()
};
let exts: Vec<Extension> = vec![
deno_telemetry::deno_telemetry::init_ops(),
deno_webidl::deno_webidl::init_ops(),
deno_url::deno_url::init_ops(),
deno_console::deno_console::init_ops(),
deno_web::deno_web::init_ops::<PermissionsContainer>(Arc::new(BlobStore::default()), None),
deno_fetch::deno_fetch::init_ops::<PermissionsContainer>(fetch_options),
deno_net::deno_net::init_ops::<PermissionsContainer>(None, None),
ext,
];
let options = RuntimeOptions {
is_main: true,
extensions: exts,
create_params: Some(
deno_core::v8::CreateParams::default().heap_limits(0, 1024 * 1024 * 128),
),
startup_snapshot: Some(RUNTIME_SNAPSHOT),
module_loader: Some(Rc::new(deno_core::FsModuleLoader)),
extension_transpiler: None,
..Default::default()
};
let (memory_limit_tx, memory_limit_rx) = mpsc::unbounded_channel::<()>();
setup_deno_runtime().expect("V8 platform init failed");
let mut js_runtime = {
let _v8_lock = V8_ISOLATE_CREATE_LOCK
.lock()
.unwrap_or_else(|e| e.into_inner());
JsRuntime::new(options)
};
js_runtime.add_near_heap_limit_callback(move |x, y| {
tracing::error!("heap limit reached: {x} {y}");
if memory_limit_tx.send(()).is_err() {
tracing::warn!(
"memory limit notification channel closed - isolate may already be terminating"
);
}
y * 2
});
let (log_sender, log_receiver) = mpsc::unbounded_channel::<String>();
{
let op_state = js_runtime.op_state();
let mut op_state = op_state.borrow_mut();
op_state.put(PermissionsContainer {});
op_state.put(MainArgs { args: initial_args });
op_state.put(LogString { s: log_sender });
}
Ok(CreatedRuntime { js_runtime, log_receiver, memory_limit_rx })
}
// ── Shared module-loading helpers ────────────────────────────────────
pub(crate) async fn load_client_module(
js_runtime: &mut JsRuntime,
env_code: &str,
) -> anyhow::Result<()> {
js_runtime
.load_side_es_module_from_code(
&deno_core::resolve_url("file:///windmill.ts")
.map_err(windmill_common::error::to_anyhow)?,
format!("{env_code}\n{WINDMILL_CLIENT}"),
)
.await
.map_err(windmill_common::error::to_anyhow)?;
Ok(())
}
pub(crate) async fn load_user_module(
js_runtime: &mut JsRuntime,
source: String,
) -> anyhow::Result<()> {
use anyhow::Context;
js_runtime
.load_side_es_module_from_code(
&deno_core::resolve_url("file:///eval.ts")
.map_err(windmill_common::error::to_anyhow)?,
source,
)
.await
.context("failed to load module")?;
Ok(())
}
/// Extract a string result from a resolved V8 global and convert to `Box<RawValue>`.
pub(crate) fn extract_global_string(
js_runtime: &mut JsRuntime,
global: v8::Global<v8::Value>,
) -> Result<Box<RawValue>, String> {
let scope = &mut js_runtime.handle_scope();
let local = v8::Local::new(scope, global);
match serde_v8::from_v8::<Option<String>>(scope, local) {
Ok(s) => Ok(unsafe_raw(s.unwrap_or_else(|| "null".to_string()))),
Err(e) => Err(format!("failed to deserialize result: {e}")),
}
}
// ── eval_fetch_timeout ───────────────────────────────────────────────
/// Execute a NativeTS script using deno_core/V8.
@@ -436,63 +577,9 @@ pub async fn eval_fetch_timeout(
}
let result_f = tokio::task::spawn_blocking(move || {
let ops = vec![op_get_static_args(), op_log()];
let ext = Extension { name: "windmill", ops: ops.into(), ..Default::default() };
let CreatedRuntime { mut js_runtime, mut log_receiver, mut memory_limit_rx } =
create_nativets_runtime(ann, spread)?;
let fetch_options = deno_fetch::Options {
root_cert_store_provider: None,
user_agent: ann.useragent.unwrap_or_else(|| "windmill/beta".to_string()),
proxy: ann.proxy.map(|x| deno_tls::Proxy {
url: x.0,
basic_auth: x
.1
.map(|(username, password)| deno_tls::BasicAuth { username, password }),
}),
..Default::default()
};
let exts: Vec<Extension> = vec![
deno_telemetry::deno_telemetry::init_ops(),
deno_webidl::deno_webidl::init_ops(),
deno_url::deno_url::init_ops(),
deno_console::deno_console::init_ops(),
deno_web::deno_web::init_ops::<PermissionsContainer>(
Arc::new(BlobStore::default()),
None,
),
deno_fetch::deno_fetch::init_ops::<PermissionsContainer>(fetch_options),
deno_net::deno_net::init_ops::<PermissionsContainer>(None, None),
ext,
];
let options = RuntimeOptions {
is_main: true,
extensions: exts,
create_params: Some(
deno_core::v8::CreateParams::default().heap_limits(0, 1024 * 1024 * 128),
),
startup_snapshot: Some(RUNTIME_SNAPSHOT),
module_loader: Some(Rc::new(deno_core::FsModuleLoader)),
extension_transpiler: None,
..Default::default()
};
let (memory_limit_tx, mut memory_limit_rx) = mpsc::unbounded_channel::<()>();
// Ensure V8 platform is initialized (idempotent, no-op if already done).
setup_deno_runtime().expect("V8 platform init failed");
// Serialize isolate creation as extra safety net against concurrent V8
// isolate creation races. The main fix is the unprotected platform in
// setup_deno_runtime(), but this provides defense in depth.
let mut js_runtime = {
let _v8_lock = V8_ISOLATE_CREATE_LOCK
.lock()
.unwrap_or_else(|e| e.into_inner());
JsRuntime::new(options)
};
// Bootstrap OpenTelemetry for fetch auto-instrumentation if OTEL was initialized.
if otel_initialized {
if let Err(e) =
js_runtime.execute_script("<otel_bootstrap>", "globalThis.__bootstrapOtel()")
@@ -501,24 +588,6 @@ pub async fn eval_fetch_timeout(
}
}
js_runtime.add_near_heap_limit_callback(move |x, y| {
tracing::error!("heap limit reached: {x} {y}");
if memory_limit_tx.send(()).is_err() {
tracing::error!("failed to send memory limit reached notification - isolate may already be terminating");
};
y * 2
});
let (log_sender, mut log_receiver) = mpsc::unbounded_channel::<String>();
{
let op_state = js_runtime.op_state();
let mut op_state = op_state.borrow_mut();
op_state.put(PermissionsContainer {});
op_state.put(MainArgs { args: spread });
op_state.put(LogString { s: log_sender });
}
*isolate_handle.lock().unwrap_or_else(|e| e.into_inner()) =
Some(js_runtime.v8_isolate().thread_safe_handle());
@@ -601,42 +670,97 @@ async fn eval_fetch(
script_entrypoint_override: Option<String>,
load_client: bool,
job_id: &Uuid,
_otel_initialized: bool,
otel_initialized: bool,
) -> windmill_common::error::Result<Box<RawValue>> {
if load_client {
if let Some(env_code) = env_code.as_ref() {
let _ = js_runtime
.load_side_es_module_from_code(
&deno_core::resolve_url("file:///windmill.ts")
.map_err(windmill_common::error::to_anyhow)?,
format!("{env_code}\n{}", WINDMILL_CLIENT.to_string()),
)
.await
.map_err(windmill_common::error::to_anyhow)?;
load_client_module(js_runtime, env_code).await?;
}
}
use anyhow::Context;
use deno_core::error::CoreError;
use windmill_common::worker::to_raw_value;
let source = format!("{}\n{expr}", env_code.unwrap_or_default());
let _ = js_runtime
.load_side_es_module_from_code(
&deno_core::resolve_url("file:///eval.ts")
.map_err(windmill_common::error::to_anyhow)?,
source.to_string(),
)
.await
.map_err(|e| {
write_error_expr(expr, &job_id);
e
})
.context("failed to load module")?;
if let Err(e) = load_user_module(js_runtime, source.clone()).await {
write_error_expr(expr, job_id);
return Err(e.into());
}
let main_override = script_entrypoint_override.unwrap_or("main".to_string());
let result = execute_main(
js_runtime,
script_entrypoint_override.as_deref(),
otel_initialized,
Some(job_id),
)
.await;
match result {
Ok(raw) => Ok(raw),
Err(ExecuteError::Script(msg)) => {
write_error_expr(expr, job_id);
Err(Error::ExecutionErr(msg))
}
Err(ExecuteError::Js { message, stack, name, source: eval_source }) => {
write_error_expr(expr, job_id);
use windmill_common::worker::to_raw_value;
let stack_head = eval_source.and_then(|(file, line_no)| {
if file == "file:///eval.ts" {
source
.lines()
.nth(line_no.saturating_sub(1))
.map(|l| format!("{l}\n"))
} else {
None
}
});
let stack_s = format!(
"{}{}",
stack_head.unwrap_or_default(),
stack.as_deref().unwrap_or_default()
);
let stack = if stack_s.is_empty() {
None
} else {
Some(stack_s)
};
Err(Error::ExecutionRawError(to_raw_value(&serde_json::json!({
"message": message,
"stack": stack,
"name": name,
}))))
}
}
}
// ── Shared execution engine ──────────────────────────────────────────
pub(crate) enum ExecuteError {
/// Non-JS error (V8 internal, init failure, deserialization)
Script(String),
/// JS exception with structured error info
Js {
message: Option<String>,
stack: Option<String>,
name: Option<String>,
/// (file_name, line_number) from the first stack frame, if in user code
source: Option<(String, usize)>,
},
}
/// Execute the `main` function from the already-loaded `eval.ts` module.
///
/// Args must already be set in `MainArgs` in the runtime's OpState.
/// Modules (`windmill.ts` and `eval.ts`) must already be loaded.
pub(crate) async fn execute_main(
js_runtime: &mut JsRuntime,
entrypoint: Option<&str>,
_otel_initialized: bool,
_job_id: Option<&Uuid>,
) -> Result<Box<RawValue>, ExecuteError> {
let main_fn = entrypoint.unwrap_or("main");
#[cfg(all(feature = "private", feature = "enterprise"))]
let otel_context_inject = if _otel_initialized {
let trace_id = job_id.as_simple().to_string();
let trace_id = _job_id
.map(|id| id.as_simple().to_string())
.unwrap_or_default();
format!(
r#"globalThis.__enterSpan?.({{
isRecording: () => true,
@@ -687,7 +811,7 @@ function processStreamIterative(res) {{
{otel_context_inject}
let args = Deno.core.ops.op_get_static_args().map(JSON.parse)
import("file:///eval.ts").then((module) => module.{main_override}(...args))
import("file:///eval.ts").then((module) => module.{main_fn}(...args))
.then(res => {{
if (isAsyncIterable(res)) {{
return processStreamIterative(res)
@@ -698,60 +822,25 @@ import("file:///eval.ts").then((module) => module.{main_override}(...args))
"#
),
)
.map_err(|e| {
write_error_expr(expr, &job_id);
e
})
.context("native script initialization")?;
.map_err(|e| ExecuteError::Script(format!("native script initialization: {e}")))?;
let fut = js_runtime.resolve(script);
let global = js_runtime
.with_event_loop_promise(fut, PollEventLoopOptions::default())
.await
.map_err(|e| {
write_error_expr(expr, &job_id);
e
});
.await;
match global {
Ok(global) => {
let scope = &mut js_runtime.handle_scope();
let local = v8::Local::new(scope, global);
let r = serde_v8::from_v8::<Option<String>>(scope, local)
.map_err(windmill_common::error::to_anyhow)?;
Ok(unsafe_raw(r.unwrap_or_else(|| "null".to_string())))
extract_global_string(js_runtime, global).map_err(|e| ExecuteError::Script(e))
}
Err(CoreError::Js(e)) => {
let stack_head = e.frames.first().and_then(|f| {
if f.file_name.as_ref().is_some_and(|x| x == "file:///eval.ts") {
Some(format!(
"{}\n",
source
.lines()
.nth((f.line_number.unwrap_or(1)) as usize - 1)
.unwrap_or("")
.to_string()
))
} else {
None
}
Err(deno_core::error::CoreError::Js(e)) => {
let source = e.frames.first().and_then(|f| {
f.file_name
.as_ref()
.map(|name| (name.clone(), f.line_number.unwrap_or(1) as usize))
});
let stack_s = format!(
"{}{}",
stack_head.unwrap_or("".to_string()),
e.stack.unwrap_or("".to_string())
);
let stack = if stack_s.is_empty() {
None
} else {
Some(stack_s)
};
Err(Error::ExecutionRawError(to_raw_value(&serde_json::json!({
"message": e.message,
"stack": stack,
"name": e.name,
}))))
Err(ExecuteError::Js { message: e.message, stack: e.stack, name: e.name, source })
}
Err(e) => Err(Error::ExecutionErr(e.print_with_cause())),
Err(e) => Err(ExecuteError::Script(e.print_with_cause())),
}
}

View File

@@ -47,9 +47,9 @@ use windmill_common::{
DB,
};
use crate::global_cache::{exists_in_cache, save_cache};
#[cfg(all(feature = "enterprise", feature = "parquet"))]
use windmill_object_store::attempt_fetch_bytes;
use crate::global_cache::{exists_in_cache, save_cache};
use windmill_parser::Typ;
@@ -977,8 +977,7 @@ pub async fn handle_bun_job(
}
};
let (cache, logs) =
crate::global_cache::load_cache(&local_path, &remote_path, false).await;
let (cache, logs) = crate::global_cache::load_cache(&local_path, &remote_path, false).await;
(cache, logs, local_path, remote_path)
} else {
(false, "".to_string(), "".to_string(), "".to_string())
@@ -1400,13 +1399,7 @@ try {{
#[cfg(feature = "deno_core")]
{
let env_code = format!(
"const process = {{ env: {{}} }};\nconst BASE_URL = '{base_internal_url}';\nconst BASE_INTERNAL_URL = '{base_internal_url}';\nprocess.env['BASE_URL'] = BASE_URL;process.env['BASE_INTERNAL_URL'] = BASE_INTERNAL_URL;\n{}",
reserved_variables
.iter()
.map(|(k, v)| format!("process.env['{}'] = '{}';\n", k, v))
.collect::<Vec<String>>()
.join("\n"));
let env_code = build_nativets_env_code(base_internal_url, &reserved_variables);
let js_code = read_file_content(&format!("{job_dir}/main.js")).await?;
let started_at = Instant::now();
let args = crate::common::build_args_map(job, client, conn)
@@ -1661,6 +1654,21 @@ pub async fn get_common_bun_proc_envs(base_internal_url: Option<&str>) -> HashMa
return bun_envs;
}
#[cfg(any(feature = "deno_core", feature = "private"))]
pub fn build_nativets_env_code(
base_internal_url: &str,
reserved_variables: &HashMap<String, String>,
) -> String {
format!(
"const process = {{ env: {{}} }};\nconst BASE_URL = '{base_internal_url}';\nconst BASE_INTERNAL_URL = '{base_internal_url}';\nprocess.env['BASE_URL'] = BASE_URL;process.env['BASE_INTERNAL_URL'] = BASE_INTERNAL_URL;\n{}",
reserved_variables
.iter()
.map(|(k, v)| format!("process.env['{}'] = '{}';", k, v))
.collect::<Vec<String>>()
.join("\n")
)
}
#[cfg(feature = "private")]
use crate::{
common::build_envs_map, dedicated_worker_oss::handle_dedicated_process, JobCompletedSender,
@@ -1672,6 +1680,212 @@ use windmill_common::variables;
#[cfg(feature = "private")]
use windmill_queue::DedicatedWorkerJob;
#[cfg(feature = "private")]
async fn handle_dedicated_bunnative(
inner_content: &str,
js_code: &str,
env_code: &str,
token: &str,
worker_name: &str,
_w_id: &str,
script_path: &str,
db: &DB,
jobs_rx: Receiver<DedicatedWorkerJob>,
killpill_rx: tokio::sync::broadcast::Receiver<()>,
job_completed_tx: JobCompletedSender,
client: &windmill_common::client::AuthedClient,
) -> Result<()> {
#[cfg(not(feature = "deno_core"))]
{
let _ = (
inner_content,
js_code,
env_code,
token,
worker_name,
script_path,
db,
jobs_rx,
killpill_rx,
job_completed_tx,
client,
);
return Err(error::Error::internal_err(
"deno_core feature is not activated but native dedicated worker was started"
.to_string(),
));
}
#[cfg(feature = "deno_core")]
{
use std::sync::Arc;
use crate::common::transform_json;
use windmill_common::worker::to_raw_value;
use windmill_queue::{append_logs, JobCompleted, MiniCompletedJob};
use windmill_runtime_nativets::PrewarmedIsolate;
let ann = windmill_runtime_nativets::get_annotation(inner_content);
let parsed_args =
windmill_parser_ts::parse_deno_signature(inner_content, true, false, None)?.args;
let arg_names: Vec<String> = parsed_args.into_iter().map(|x| x.name).collect();
let env_code = env_code.to_string();
let js_code = js_code.to_string();
let mut warm = PrewarmedIsolate::spawn(
env_code.clone(),
js_code.clone(),
ann.clone(),
arg_names.clone(),
);
let init_log = format!("dedicated worker nativets: {worker_name}\n\n");
let alive = true;
let mut killpill_rx = killpill_rx;
let mut jobs_rx = jobs_rx;
loop {
tokio::select! {
biased;
_ = killpill_rx.recv(), if alive => {
tracing::info!("received killpill for nativets dedicated worker");
break;
},
job = jobs_rx.recv(), if alive => {
if let Some(DedicatedWorkerJob { job, flow_runners, done_tx }) = job {
let id = job.id;
tracing::info!(
"received job on nativets dedicated worker for {script_path}: {id}"
);
let args = if let Some(args) = job.args.as_ref() {
if let Some(x) = transform_json(
client, &job.workspace_id, &args.0, &job, &db.into(),
).await? {
serde_json::to_string(&x)
.unwrap_or_else(|_| "{}".to_string())
} else {
serde_json::to_string(&args)
.unwrap_or_else(|_| "{}".to_string())
}
} else {
"{}".to_string()
};
if let Err(e) = warm.wait_ready().await {
tracing::error!("pre-warmed isolate failed during init: {e}");
let result = Arc::new(to_raw_value(&serde_json::json!({
"message": format!("isolate init failed: {e}"),
"name": "Error",
})));
append_logs(&id, &job.workspace_id, init_log.clone(), &db.into()).await;
job_completed_tx.send_job(JobCompleted {
job: MiniCompletedJob::from(job),
result,
result_columns: None,
mem_peak: 0,
canceled_by: None,
success: false,
cached_res_path: None,
token: token.to_string(),
duration: None,
preprocessed_args: None,
has_stream: Some(false),
from_cache: None,
flow_runners,
done_tx,
}, true).await?;
warm = PrewarmedIsolate::spawn(
env_code.clone(),
js_code.clone(),
ann.clone(),
arg_names.clone(),
);
continue;
}
let executing = warm.start_execution(args);
warm = PrewarmedIsolate::spawn(
env_code.clone(),
js_code.clone(),
ann.clone(),
arg_names.clone(),
);
match executing.wait().await {
Ok(prewarmed_result) => {
let mut logs = init_log.clone();
if !prewarmed_result.logs.is_empty() {
logs.push_str(&prewarmed_result.logs);
}
append_logs(&id, &job.workspace_id, logs, &db.into()).await;
let (result, success) = match prewarmed_result.result {
Ok(raw) => (Arc::new(raw), true),
Err(e) => (
Arc::new(to_raw_value(&serde_json::json!({
"message": e,
"name": "Error",
}))),
false,
),
};
job_completed_tx.send_job(JobCompleted {
job: MiniCompletedJob::from(job),
result,
result_columns: None,
mem_peak: 0,
canceled_by: None,
success,
cached_res_path: None,
token: token.to_string(),
duration: None,
preprocessed_args: None,
has_stream: Some(false),
from_cache: None,
flow_runners,
done_tx,
}, true).await?;
}
Err(e) => {
tracing::error!("isolate execution failed: {e}");
append_logs(&id, &job.workspace_id, init_log.clone(), &db.into()).await;
let result = Arc::new(to_raw_value(&serde_json::json!({
"message": format!("{e}"),
"name": "Error",
})));
job_completed_tx.send_job(JobCompleted {
job: MiniCompletedJob::from(job),
result,
result_columns: None,
mem_peak: 0,
canceled_by: None,
success: false,
cached_res_path: None,
token: token.to_string(),
duration: None,
preprocessed_args: None,
has_stream: Some(false),
from_cache: None,
flow_runners: None,
done_tx: None,
}, true).await?;
}
}
} else {
tracing::debug!("job channel closed for nativets dedicated worker");
break;
}
}
}
}
Ok(())
}
}
#[cfg(feature = "private")]
pub async fn start_worker(
requirements_o: Option<String>,
@@ -1704,7 +1918,9 @@ pub async fn start_worker(
let mut annotation = windmill_common::worker::TypeScriptAnnotations::parse(inner_content);
//TODO: remove this when bun dedicated workers work without issues
annotation.nodejs = true;
if !annotation.native {
annotation.nodejs = true;
}
let context = variables::get_reserved_variables(
&Connection::from(db.clone()),
@@ -1728,6 +1944,81 @@ pub async fn start_worker(
.await;
let context_envs = build_envs_map(context.to_vec()).await;
if annotation.native {
// Native (V8) dedicated worker: bundle the code and dispatch to V8 instead of a subprocess.
let main_code = remove_pinned_imports(inner_content)?;
write_file(job_dir, "main.ts", &main_code)?;
if let Some(reqs) = requirements_o.as_ref() {
let (pkg, lock, empty, is_binary) = split_lockfile(reqs);
write_file(job_dir, "package.json", pkg)?;
if let Some(lock) = lock {
if !empty {
write_lock(lock, job_dir, is_binary).await?;
install_bun_lockfile(
&mut mem_peak,
&mut canceled_by,
&Uuid::nil(),
w_id,
Some(&Connection::from(db.clone())),
job_dir,
worker_name,
common_bun_proc_envs.clone(),
annotation.npm,
&mut None,
)
.await?;
}
}
}
build_loader(
job_dir,
base_internal_url,
token,
w_id,
script_path,
LoaderMode::BrowserBundle,
)
.await?;
generate_bun_bundle(
job_dir,
w_id,
&Uuid::nil(),
worker_name,
Some(&Connection::from(db.clone())),
None,
&mut mem_peak,
&mut canceled_by,
&common_bun_proc_envs,
&mut None,
)
.await?;
let js_code = read_file_content(&format!("{job_dir}/main.js")).await?;
let reserved_variables: HashMap<String, String> = context
.iter()
.map(|x| (x.name.clone(), x.value.clone()))
.collect();
let env_code = build_nativets_env_code(base_internal_url, &reserved_variables);
return handle_dedicated_bunnative(
inner_content,
&js_code,
&env_code,
token,
worker_name,
w_id,
script_path,
db,
jobs_rx,
killpill_rx,
job_completed_tx,
&client,
)
.await;
}
let mut format = BundleFormat::Cjs;
if let Some(codebase) = codebase.as_ref() {
let pulled_codebase = pull_codebase(w_id, codebase, job_dir).await?;

View File

@@ -37,7 +37,7 @@ async function verifyOutputs(uuids: string[], workspace: string) {
console.log(`Incorrect results: ${incorrectResults}`);
}
export const NON_TEST_TAGS = ["deno", "python", "go", "bash", "dedicated", "bun", "nativets", "flow"]
export const NON_TEST_TAGS = ["deno", "python", "go", "bash", "dedicated", "bun", "nativets", "dedicated_nativets", "flow"]
export async function main({
host,
email,
@@ -146,7 +146,7 @@ export async function main({
}
if (
["deno", "python", "go", "bash", "dedicated", "bun", "nativets"].includes(
["deno", "python", "go", "bash", "dedicated", "bun", "nativets", "dedicated_nativets"].includes(
kind
)
) {
@@ -165,7 +165,7 @@ export async function main({
kind: "noop",
});
} else if (
["deno", "python", "go", "bash", "dedicated", "bun", "nativets"].includes(
["deno", "python", "go", "bash", "dedicated", "bun", "nativets", "dedicated_nativets"].includes(
kind
)
) {
@@ -336,6 +336,7 @@ export async function main({
!noVerify &&
kind !== "noop" &&
kind !== "nativets" &&
kind !== "dedicated_nativets" &&
!kind.startsWith("flow:") &&
!kind.startsWith("script:")
) {
@@ -386,7 +387,7 @@ if (import.meta.main) {
)
.option(
"--kind <kind:string>",
"Specifiy the benchmark kind among: deno, identity, python, go, bash, dedicated, bun, noop, 2steps, nativets",
"Specifiy the benchmark kind among: deno, identity, python, go, bash, dedicated, bun, noop, 2steps, nativets, dedicated_nativets",
{
required: true,
}

View File

@@ -25,7 +25,7 @@ async function waitForDeployment(workspace: string, hash: string) {
if (resp.lock !== null) {
return;
}
} catch (err) { }
} catch (err) {}
await sleep(0.5);
}
throw new Error("Script did not deploy in time");
@@ -49,7 +49,7 @@ async function waitForDedicatedWorker(workspace: string, path: string) {
export async function createBenchScript(
scriptPattern: string,
workspace: string
workspace: string,
) {
const path = `f/benchmarks/${scriptPattern}`;
const exists = await windmill.ScriptService.existsScriptByPath({
@@ -93,11 +93,14 @@ export async function createBenchScript(
language = "deno";
} else if (scriptPattern === "nativets") {
scriptContent =
'export async function main(){ return (await fetch(BASE_URL + "/api/version")).text() }';
language = "nativets";
'//native\nexport async function main(){ return (await fetch(BASE_URL + "/api/version")).text() }';
language = "bunnative";
} else if (scriptPattern === "dedicated_nativets") {
scriptContent = "//native\nexport function main(){ return 42; }";
language = "bunnative";
} else {
throw new Error(
"Could not create script for script pattern " + scriptPattern
"Could not create script for script pattern " + scriptPattern,
);
}
@@ -109,7 +112,8 @@ export async function createBenchScript(
summary: scriptPattern + " benchmark",
description: "",
language: language as api.NewScript.language,
dedicated_worker: scriptPattern === "dedicated",
dedicated_worker:
scriptPattern === "dedicated" || scriptPattern === "dedicated_nativets",
schema: {
$schema: "https://json-schema.org/draft/2020-12/schema",
properties: schemaProperties,
@@ -123,7 +127,7 @@ export async function createBenchScript(
console.log("Created benchmark script at path", path);
if (scriptPattern === "dedicated") {
if (scriptPattern === "dedicated" || scriptPattern === "dedicated_nativets") {
await waitForDedicatedWorker(workspace, path);
}
}
@@ -246,11 +250,15 @@ export const getFlowPayload = (flowPattern: string): api.FlowPreview => {
input_transforms: {},
language: api.RawScript.language.BASH,
type: "rawscript",
content: "# let's bloat that bash script, 3.. 2.. 1.. BOOM\n".repeat(100) + `if [[ -z $\{WM_FLOW_JOB_ID+x\} ]]; then\necho "not set"\nelif [[ -z "$WM_FLOW_JOB_ID" ]]; then\necho "empty"\nelse\necho "$WM_FLOW_JOB_ID"\nfi`,
content:
"# let's bloat that bash script, 3.. 2.. 1.. BOOM\n".repeat(
100,
) +
`if [[ -z $\{WM_FLOW_JOB_ID+x\} ]]; then\necho "not set"\nelif [[ -z "$WM_FLOW_JOB_ID" ]]; then\necho "empty"\nelse\necho "$WM_FLOW_JOB_ID"\nfi`,
},
}
},
],
}
},
};
} else {
return {

View File

@@ -0,0 +1,6 @@
[
{ "kind": "nativets", "jobs": 5000, "noSave": true },
{ "kind": "nativets", "jobs": 10000 },
{ "kind": "dedicated_nativets", "jobs": 5000, "noSave": true },
{ "kind": "dedicated_nativets", "jobs": 10000 }
]

View File

@@ -61,7 +61,7 @@
let selectedTagsInfo: SvelteMap<string, SelectedTagInfo> = $state(new SvelteMap())
// Languages that support dedicated workers
const DEDICATED_WORKER_LANGUAGES = ['python3', 'bun', 'deno']
const DEDICATED_WORKER_LANGUAGES = ['python3', 'bun', 'bunnative', 'deno']
// Resolve workspace script languages and filter to supported languages
async function resolveAndFilterRunners(
@@ -603,7 +603,7 @@
<div class="bg-surface-secondary border-t">
{#if runnable.runners.length === 0}
<div class="px-9 py-1.5 text-xs text-tertiary italic">
No eligible steps (python3/bun/deno)
No eligible steps (python3/bun/bunnative/deno)
</div>
{:else}
{#each runnable.runners as runner (runner.stepId)}

View File

@@ -1426,6 +1426,7 @@
disabled={!$enterpriseLicense ||
isCloudHosted() ||
(script.language != 'bun' &&
script.language != 'bunnative' &&
script.language != 'python3' &&
script.language != 'deno')}
size="sm"
@@ -1455,8 +1456,8 @@
>
In this mode, the script is meant to be run on dedicated workers that run
the script at native speed. Can reach &gt;1500rps per dedicated worker. Only
available on enterprise edition and for Python3, Deno and Bun. For other
languages, the efficiency is already on par with deidcated workers since
available on enterprise edition and for Python3, Deno, Bun and Bunnative. For other
languages, the efficiency is already on par with dedicated workers since
they do not spawn a full runtime</Tooltip
>
{/snippet}