From 37f29d7b59631e4d987376cde59a4d930e635bfc Mon Sep 17 00:00:00 2001 From: hugocasa Date: Fri, 20 Feb 2026 17:28:40 +0100 Subject: [PATCH] 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] --- backend/Cargo.lock | 1 + backend/Cargo.toml | 1 + backend/ee-repo-ref.txt | 2 +- backend/tests/nativets_dedicated.rs | 275 +++++++++++++ .../src/dedicated.rs | 155 +++++++ backend/windmill-runtime-nativets/src/lib.rs | 389 +++++++++++------- backend/windmill-worker/src/bun_executor.rs | 313 +++++++++++++- benchmarks/benchmark_oneoff.ts | 9 +- benchmarks/lib.ts | 28 +- benchmarks/suite_dedicated_nativets.json | 6 + .../DedicatedWorkersSelector.svelte | 4 +- .../src/lib/components/ScriptBuilder.svelte | 5 +- 12 files changed, 1008 insertions(+), 180 deletions(-) create mode 100644 backend/tests/nativets_dedicated.rs create mode 100644 backend/windmill-runtime-nativets/src/dedicated.rs create mode 100644 benchmarks/suite_dedicated_nativets.json diff --git a/backend/Cargo.lock b/backend/Cargo.lock index afdfd609c1..1f04f36fa5 100644 --- a/backend/Cargo.lock +++ b/backend/Cargo.lock @@ -15778,6 +15778,7 @@ dependencies = [ "windmill-indexer", "windmill-object-store", "windmill-operator", + "windmill-parser-ts", "windmill-queue", "windmill-runtime-nativets", "windmill-test-utils", diff --git a/backend/Cargo.toml b/backend/Cargo.toml index f4933c459e..05acd39579 100644 --- a/backend/Cargo.toml +++ b/backend/Cargo.toml @@ -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 diff --git a/backend/ee-repo-ref.txt b/backend/ee-repo-ref.txt index f94cdbf754..b53996ca96 100644 --- a/backend/ee-repo-ref.txt +++ b/backend/ee-repo-ref.txt @@ -1 +1 @@ -d842747738a2f10fc2fd0cd61f536efffcb45e41 +5f8105b808f3f0186fdf5132d2ee602d8a14aa17 diff --git a/backend/tests/nativets_dedicated.rs b/backend/tests/nativets_dedicated.rs new file mode 100644 index 0000000000..4b730b32c7 --- /dev/null +++ b/backend/tests/nativets_dedicated.rs @@ -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) { + 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 = 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, + ) -> Vec> { + 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, + ) -> Vec> { + 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 { + 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)); + } +} diff --git a/backend/windmill-runtime-nativets/src/dedicated.rs b/backend/windmill-runtime-nativets/src/dedicated.rs new file mode 100644 index 0000000000..5533c36262 --- /dev/null +++ b/backend/windmill-runtime-nativets/src/dedicated.rs @@ -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, String>, + pub logs: String, +} + +pub struct ExecutingIsolate { + result_rx: tokio::sync::oneshot::Receiver, + handle: tokio::task::JoinHandle>, +} + +impl ExecutingIsolate { + pub async fn wait(self) -> anyhow::Result { + 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>, + result_rx: Option>, + ready_rx: Option>, + handle: Option>>, +} + +/// Parse a JSON args object and reorder into positional args matching `arg_names`. +fn args_to_positional(args_json: &str, arg_names: &[String]) -> Vec>> { + let map: HashMap> = 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, + ) -> Self { + let (args_tx, args_rx) = tokio::sync::oneshot::channel::(); + let (result_tx, result_rx) = tokio::sync::oneshot::channel::(); + 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"), + } + } +} diff --git a/backend/windmill-runtime-nativets/src/lib.rs b/backend/windmill-runtime-nativets/src/lib.rs index 4f1f88daa8..a523106cbc 100644 --- a/backend/windmill-runtime-nativets/src/lib.rs +++ b/backend/windmill-runtime-nativets/src/lib.rs @@ -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>>, +pub(crate) struct MainArgs { + pub(crate) args: Vec>>, } struct LogString { pub s: mpsc::UnboundedSender, } +#[derive(Clone)] pub struct NativeAnnotation { pub useragent: Option, 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::>(); 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>, #[string] log: &str) { } } +// ── Shared V8 runtime creation ─────────────────────────────────────── + +pub(crate) struct CreatedRuntime { + pub(crate) js_runtime: JsRuntime, + pub(crate) log_receiver: mpsc::UnboundedReceiver, + 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>>, +) -> anyhow::Result { + 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 = 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::(Arc::new(BlobStore::default()), None), + deno_fetch::deno_fetch::init_ops::(fetch_options), + deno_net::deno_net::init_ops::(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::(); + + { + 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`. +pub(crate) fn extract_global_string( + js_runtime: &mut JsRuntime, + global: v8::Global, +) -> Result, String> { + let scope = &mut js_runtime.handle_scope(); + let local = v8::Local::new(scope, global); + match serde_v8::from_v8::>(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 = 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::( - Arc::new(BlobStore::default()), - None, - ), - deno_fetch::deno_fetch::init_ops::(fetch_options), - deno_net::deno_net::init_ops::(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("", "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::(); - - { - 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, load_client: bool, job_id: &Uuid, - _otel_initialized: bool, + otel_initialized: bool, ) -> windmill_common::error::Result> { 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, + stack: Option, + name: Option, + /// (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, 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::>(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())), } } diff --git a/backend/windmill-worker/src/bun_executor.rs b/backend/windmill-worker/src/bun_executor.rs index b7361eb2f3..64c434db04 100644 --- a/backend/windmill-worker/src/bun_executor.rs +++ b/backend/windmill-worker/src/bun_executor.rs @@ -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::>() - .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 { + 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::>() + .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, + 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 = 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, @@ -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 = 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?; diff --git a/benchmarks/benchmark_oneoff.ts b/benchmarks/benchmark_oneoff.ts index 6685eae1c5..0cd4c3483f 100644 --- a/benchmarks/benchmark_oneoff.ts +++ b/benchmarks/benchmark_oneoff.ts @@ -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 ", - "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, } diff --git a/benchmarks/lib.ts b/benchmarks/lib.ts index 077a9749fd..5dd7580ccc 100644 --- a/benchmarks/lib.ts +++ b/benchmarks/lib.ts @@ -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 { diff --git a/benchmarks/suite_dedicated_nativets.json b/benchmarks/suite_dedicated_nativets.json new file mode 100644 index 0000000000..678532a616 --- /dev/null +++ b/benchmarks/suite_dedicated_nativets.json @@ -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 } +] diff --git a/frontend/src/lib/components/DedicatedWorkersSelector.svelte b/frontend/src/lib/components/DedicatedWorkersSelector.svelte index 336c2f263b..1ed9d36a7c 100644 --- a/frontend/src/lib/components/DedicatedWorkersSelector.svelte +++ b/frontend/src/lib/components/DedicatedWorkersSelector.svelte @@ -61,7 +61,7 @@ let selectedTagsInfo: SvelteMap = $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 @@
{#if runnable.runners.length === 0}
- No eligible steps (python3/bun/deno) + No eligible steps (python3/bun/bunnative/deno)
{:else} {#each runnable.runners as runner (runner.stepId)} diff --git a/frontend/src/lib/components/ScriptBuilder.svelte b/frontend/src/lib/components/ScriptBuilder.svelte index 7e57ae0660..a8f60f4734 100644 --- a/frontend/src/lib/components/ScriptBuilder.svelte +++ b/frontend/src/lib/components/ScriptBuilder.svelte @@ -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 >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 {/snippet}