Files
windmill/backend/windmill-test-utils/src/lib.rs
HugoCasa 876a9cfc8e feat: batch job pulling for native workers
Reduce DB polling overhead for native workers by batch-fetching jobs
server-side and serving them from an in-memory buffer via HTTP.

- Add batch_pull() in windmill-queue: single SELECT...FOR UPDATE SKIP LOCKED LIMIT N
- Add batch pull SQL helpers (make_batch_pull_query, format_batch_pull_query)
- OSS stubs for agent-workers accept batch_buffer parameter (4-tuple return)
- Native workers self-sign JWT and pull jobs via HTTP when co-located with server
- Add uses_batch_http_pull column to worker_ping for server-side tracking
- Worker pull loop: HTTP batch pull when client available, SQL otherwise

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 16:08:28 +01:00

914 lines
29 KiB
Rust

#![allow(dead_code)]
use std::{future::Future, str::FromStr};
use futures::Stream;
use serde::Serialize;
use serde_json::json;
use sqlx::{postgres::PgListener, Pool, Postgres};
use uuid::Uuid;
use windmill_api_client::types::NewScript;
use windmill_common::flow_status::FlowStatusModule;
use windmill_common::{
jobs::{JobKind, JobPayload, RawCode},
jwt::JWT_SECRET,
scripts::{ScriptHash, ScriptLang},
worker::{Connection, WORKER_CONFIG},
KillpillSender,
};
use windmill_queue::PushIsolationLevel;
pub async fn init_client(db: Pool<Postgres>) -> (windmill_api_client::Client, u16, ApiServer) {
initialize_tracing().await;
let server = ApiServer::start(db).await.unwrap();
let port = server.addr.port();
let client = windmill_api_client::create_client(
&format!("http://localhost:{port}"),
"SECRET_TOKEN".to_string(),
);
(client, port, server)
}
pub async fn init_client_agent_mode(
db: Pool<Postgres>,
) -> (windmill_api_client::Client, u16, ApiServer) {
initialize_tracing().await;
set_jwt_secret().await;
let server = ApiServer::start_agent_mode(db).await.unwrap();
let port = server.addr.port();
let client = windmill_api_client::create_client(
&format!("http://localhost:{port}"),
"SECRET_TOKEN".to_string(),
);
(client, port, server)
}
/// it's important this is unique between tests as there is one prometheus registry and
/// run_worker shouldn't register the same metric with the same worker name more than once.
///
/// this must fit in varchar(50)
fn next_worker_name() -> String {
use std::sync::atomic::{AtomicUsize, Ordering::SeqCst};
static ID: AtomicUsize = AtomicUsize::new(0);
// n.b.: when tests are run with RUST_TEST_THREADS or --test-threads set to 1, the name
// will be "main"... The id provides uniqueness & thread_name gives context.
let id = ID.fetch_add(1, SeqCst);
let thread = std::thread::current();
let thread_name = thread
.name()
.map(|s| {
s.len()
.checked_sub(39)
.and_then(|start| s.get(start..))
.unwrap_or(s)
})
.unwrap_or("no thread name");
format!("{id}/worker-{thread_name}")
}
pub struct ApiServer {
pub addr: std::net::SocketAddr,
#[allow(unused)]
tx: tokio::sync::broadcast::Sender<()>,
#[allow(unused)]
task: tokio::task::JoinHandle<anyhow::Result<()>>,
}
impl ApiServer {
pub async fn start(db: Pool<Postgres>) -> anyhow::Result<Self> {
Self::start_inner(db, false).await
}
pub async fn start_agent_mode(db: Pool<Postgres>) -> anyhow::Result<Self> {
Self::start_inner(db, true).await
}
/// Start the API server with server_mode=true so trigger listeners are active.
/// Alias for `start_agent_mode` with a clearer name for trigger e2e tests.
pub async fn start_with_listeners(db: Pool<Postgres>) -> anyhow::Result<Self> {
Self::start_inner(db, true).await
}
async fn start_inner(db: Pool<Postgres>, agent_mode: bool) -> anyhow::Result<Self> {
let (tx, rx) = tokio::sync::broadcast::channel::<()>(1);
let sock = tokio::net::TcpListener::bind("127.0.0.1:0")
.await
.map_err(|e| anyhow::anyhow!("failed to bind TCP listener: {}", e))?;
let addr = sock
.local_addr()
.map_err(|e| anyhow::anyhow!("failed to get local address: {}", e))?;
drop(sock);
let (port_tx, _port_rx) = tokio::sync::oneshot::channel::<String>();
let name = next_worker_name();
tracing::info!("starting api server for name={name}");
let task = tokio::task::spawn(windmill_api::run_server(
db.clone(),
None,
None,
addr,
rx,
port_tx,
agent_mode,
false,
format!("http://localhost:{}", addr.port()),
Some(name.clone()),
));
tracing::info!("waiting for server port for name={name}");
if let Err(e) = _port_rx.await {
tracing::error!("failed to receive port for name={name}: {e}");
tokio::time::sleep(std::time::Duration::from_secs(1)).await;
return Err(anyhow::anyhow!(
"failed to receive port for name={name}: {e}"
));
}
// clear the cache between tests
windmill_common::cache::clear();
Ok(Self { addr, tx, task })
}
#[allow(unused)]
pub async fn close(self) -> anyhow::Result<()> {
println!("closing api server");
let Self { tx, task, .. } = self;
drop(tx);
task.await.unwrap()
}
}
#[derive(Debug, Clone)]
pub struct RunJob {
pub payload: JobPayload,
pub args: serde_json::Map<String, serde_json::Value>,
pub scheduled_for_o: Option<chrono::DateTime<chrono::Utc>>,
pub email: String,
}
impl From<JobPayload> for RunJob {
fn from(payload: JobPayload) -> Self {
Self {
payload,
args: Default::default(),
scheduled_for_o: None,
email: "test@windmill.dev".to_string(),
}
}
}
impl RunJob {
pub fn arg<S: Into<String>>(mut self, k: S, v: serde_json::Value) -> Self {
self.args.insert(k.into(), v);
self
}
pub fn push_arg_scheduled_for_o(
mut self,
scheduled_for_o: Option<chrono::DateTime<chrono::Utc>>,
) -> Self {
self.scheduled_for_o = scheduled_for_o;
self
}
pub fn email(mut self, email: impl Into<String>) -> Self {
self.email = email.into();
self
}
pub async fn push(self, db: &Pool<Postgres>) -> Uuid {
let RunJob { payload, args, scheduled_for_o, email } = self;
let mut hm_args = std::collections::HashMap::new();
for (k, v) in args {
hm_args.insert(k, windmill_common::worker::to_raw_value(&v));
}
let tx = PushIsolationLevel::IsolatedRoot(db.clone());
let (uuid, tx) = windmill_queue::push(
db,
tx,
"test-workspace",
payload,
windmill_queue::PushArgs::from(&hm_args),
/* user */ "test-user",
/* email */ &email,
/* permissioned_as */ "u/test-user".to_string(),
/* token_prefix */ None,
scheduled_for_o,
/* schedule_path */ None,
/* parent_job */ None,
/* root job */ None,
/* flow_innermost_root_job */ None,
/* job_id */ None,
/* is_flow_step */ false,
/* same_worker */ false,
None,
true,
None,
None,
None,
None,
None,
false,
None,
None,
None,
)
.await
.expect("push has to succeed");
tx.commit().await.unwrap();
uuid
}
/// Push the job as a specific user (for testing permissions)
pub async fn push_as(self, db: &Pool<Postgres>, username: &str, email: &str) -> Uuid {
let RunJob { payload, args, scheduled_for_o, .. } = self;
let mut hm_args = std::collections::HashMap::new();
for (k, v) in args {
hm_args.insert(k, windmill_common::worker::to_raw_value(&v));
}
let tx = PushIsolationLevel::IsolatedRoot(db.clone());
let (uuid, tx) = windmill_queue::push(
db,
tx,
"test-workspace",
payload,
windmill_queue::PushArgs::from(&hm_args),
username,
email,
format!("u/{}", username),
/* token_prefix */ None,
scheduled_for_o,
/* schedule_path */ None,
/* parent_job */ None,
/* root job */ None,
/* flow_innermost_root_job */ None,
/* job_id */ None,
/* is_flow_step */ false,
/* same_worker */ false,
None,
true,
None,
None,
None,
None,
None,
false,
None,
None,
None,
)
.await
.expect("push has to succeed");
tx.commit().await.unwrap();
uuid
}
/// push the job, spawn a worker, wait until the job is in completed_job
pub async fn run_until_complete(
self,
db: &Pool<Postgres>,
agent_mode: bool,
port: u16,
) -> CompletedJob {
let uuid = self.push(db).await;
let listener = listen_for_completed_jobs(db).await;
let conn = match agent_mode {
false => Connection::Sql(db.clone()),
#[cfg(all(feature = "private", feature = "agent_worker_server"))]
true => testing_http_connection(port).await,
#[cfg(not(all(feature = "private", feature = "agent_worker_server")))]
true => {
panic!("to use agent worker test, you need to enable 'agent_worker_server' feature")
}
};
in_test_worker(conn, listener.find(&uuid), port).await;
completed_job(uuid, db).await
}
/// push the job, spawn a worker, wait until the job is in completed_job
pub async fn run_until_complete_with<F: Future<Output = ()>>(
self,
db: &Pool<Postgres>,
agent_mode: bool,
port: u16,
test: impl Fn(Uuid) -> F,
) -> CompletedJob {
let uuid = self.push(db).await;
let listener = listen_for_completed_jobs(db).await;
test(uuid).await;
let conn = match agent_mode {
false => Connection::Sql(db.clone()),
#[cfg(all(feature = "private", feature = "agent_worker_server"))]
true => testing_http_connection(port).await,
#[cfg(not(all(feature = "private", feature = "agent_worker_server")))]
true => {
panic!("to use agent worker test, you need to enable 'agent_worker_server' feature")
}
};
in_test_worker(conn, listener.find(&uuid), port).await;
completed_job(uuid, db).await
}
}
pub async fn run_job_in_new_worker_until_complete(
db: &Pool<Postgres>,
agent_mode: bool,
job: JobPayload,
port: u16,
) -> CompletedJob {
RunJob::from(job)
.run_until_complete(db, agent_mode, port)
.await
}
/// Start a worker with a timeout and run a future, until the worker quits or we time out.
///
/// Cleans up the worker before resolving.
pub async fn in_test_worker<Fut: std::future::Future>(
// db: &Pool<Postgres>,
// If set to http, worker will be started in agent mode.
conn: impl Into<Connection>,
inner: Fut,
port: u16,
) -> <Fut as std::future::Future>::Output {
set_jwt_secret().await;
let (quit, worker) = spawn_test_worker(&conn.into(), port);
let worker = tokio::time::timeout(std::time::Duration::from_secs(60), worker);
tokio::pin!(worker);
let res = tokio::select! {
biased;
res = inner => res,
res = &mut worker => match
res.expect("worker timed out")
.expect("worker panicked") {
_ => panic!("worker quit early"),
},
};
/* ensure the worker quits before we return */
quit.send();
let _: () = worker
.await
.expect("worker timed out")
.expect("worker panicked");
res
}
pub fn spawn_test_worker(
conn: &Connection,
port: u16,
) -> (KillpillSender, tokio::task::JoinHandle<()>) {
#[cfg(feature = "deno_core")]
windmill_runtime_nativets::setup_deno_runtime().expect("V8 init failed");
std::fs::DirBuilder::new()
.recursive(true)
.create(&*windmill_worker::GO_BIN_CACHE_DIR)
.expect("could not create initial worker dir");
let (tx, rx) = KillpillSender::new(1);
let worker_instance: &str = "test worker instance";
let worker_name: String = next_worker_name();
let ip: &str = Default::default();
let conn = conn.to_owned();
let tx2 = tx.clone();
let future = async move {
let base_internal_url = format!("http://localhost:{}", port);
{
let mut wc = WORKER_CONFIG.write().await;
wc.worker_tags = windmill_common::worker::DEFAULT_TAGS.clone();
wc.priority_tags_sorted = vec![windmill_common::worker::PriorityTags {
priority: 0,
tags: wc.worker_tags.clone(),
}];
windmill_common::worker::store_suspended_pull_query(&wc).await;
windmill_common::worker::store_pull_query(&wc).await;
}
windmill_worker::run_worker(
&conn,
worker_instance,
worker_name,
1,
1,
ip,
rx,
tx2,
&base_internal_url,
None,
)
.await
};
(tx, tokio::task::spawn(future))
}
pub async fn listen_for_completed_jobs(db: &Pool<Postgres>) -> impl Stream<Item = Uuid> + Unpin {
listen_for_uuid_on(db, "completed").await
}
pub async fn listen_for_queue(db: &Pool<Postgres>) -> impl Stream<Item = Uuid> + Unpin {
listen_for_uuid_on(db, "queued").await
}
pub async fn listen_for_uuid_on(
db: &Pool<Postgres>,
channel: &'static str,
) -> impl Stream<Item = Uuid> + Unpin {
let mut listener = PgListener::connect_with(db).await.unwrap();
listener.listen(channel).await.unwrap();
Box::pin(futures::stream::unfold(
listener,
|mut listener| async move {
let uuid = listener
.try_recv()
.await
.unwrap()
.expect("lost database connection")
.payload()
.parse::<Uuid>()
.expect("invalid uuid");
Some((uuid, listener))
},
))
}
pub async fn completed_job(uuid: Uuid, db: &Pool<Postgres>) -> CompletedJob {
sqlx::query_as::<_, CompletedJob>(
"SELECT j.id, j.workspace_id, j.parent_job, j.created_by, j.created_at, c.duration_ms,
c.status = 'success' OR c.status = 'skipped' AS success, j.runnable_id AS script_hash, j.runnable_path AS script_path,
j.args, c.result, FALSE AS deleted, j.raw_code, c.status = 'canceled' AS canceled,
c.canceled_by, c.canceled_reason, j.kind AS job_kind,
CASE WHEN j.trigger_kind = 'schedule'::job_trigger_kind THEN j.trigger END AS schedule_path,
j.permissioned_as, COALESCE(c.flow_status, c.workflow_as_code_status) AS flow_status, j.raw_flow,
j.flow_step_id IS NOT NULL AS is_flow_step, j.script_lang AS language, c.started_at,
c.status = 'skipped' AS is_skipped, j.raw_lock, j.permissioned_as_email AS email, j.visible_to_owner,
c.memory_peak AS mem_peak, j.tag, j.priority, NULL::TEXT AS logs, c.result_columns,
j.script_entrypoint_override, j.preprocessed, c.result->'wm_labels' as labels
FROM v2_job_completed c JOIN v2_job j USING (id) WHERE j.id = $1",
)
.bind(uuid)
.fetch_one(db)
.await
.unwrap()
}
#[axum::async_trait(?Send)]
pub trait StreamFind: futures::Stream + Unpin + Sized {
async fn find(self, item: &Self::Item) -> Option<Self::Item>
where
for<'l> &'l Self::Item: std::cmp::PartialEq,
{
use futures::{future::ready, StreamExt};
self.filter(|i| ready(i == item)).next().await
}
}
impl<T: futures::Stream + Unpin + Sized> StreamFind for T {}
pub fn get_module(cjob: &CompletedJob, id: &str) -> Option<FlowStatusModule> {
cjob.flow_status.clone().and_then(|fs| {
use windmill_common::flow_status::FlowStatus;
find_module_in_vec(
serde_json::from_value::<FlowStatus>(fs).unwrap().modules,
id,
)
})
}
fn find_module_in_vec(modules: Vec<FlowStatusModule>, id: &str) -> Option<FlowStatusModule> {
modules.into_iter().find(|s| s.id() == id)
}
pub async fn set_jwt_secret() {
let secret = "mytestsecret".to_string();
let mut l = JWT_SECRET.write().await;
*l = secret;
}
#[derive(Debug, sqlx::FromRow, Serialize)]
pub struct CompletedJob {
pub workspace_id: String,
pub id: Uuid,
pub parent_job: Option<Uuid>,
pub created_by: String,
pub created_at: chrono::DateTime<chrono::Utc>,
pub started_at: chrono::DateTime<chrono::Utc>,
pub duration_ms: i64,
pub success: bool,
pub script_path: Option<String>,
pub args: Option<serde_json::Value>,
pub result: Option<serde_json::Value>,
pub logs: Option<String>,
pub deleted: bool,
pub raw_code: Option<String>,
pub canceled: bool,
pub canceled_by: Option<String>,
pub canceled_reason: Option<String>,
pub schedule_path: Option<String>,
pub permissioned_as: String,
pub flow_status: Option<serde_json::Value>,
pub raw_flow: Option<serde_json::Value>,
pub is_flow_step: bool,
pub is_skipped: bool,
pub email: String,
pub visible_to_owner: bool,
pub mem_peak: Option<i32>,
pub tag: String,
pub script_hash: Option<ScriptHash>,
pub language: Option<ScriptLang>,
pub job_kind: JobKind,
}
impl CompletedJob {
pub fn json_result(&self) -> Option<serde_json::Value> {
self.result.clone()
}
}
pub async fn initialize_tracing() {
use std::sync::Once;
static ONCE: Once = Once::new();
ONCE.call_once(|| {
let _ = windmill_common::tracing_init::initialize_tracing(
"test",
&windmill_common::utils::Mode::Standalone,
"test",
);
});
}
pub async fn test_for_versions<F: Future<Output = ()>>(
constraints: impl Iterator<Item = &'static windmill_common::min_version::VersionConstraint>,
test: impl Fn() -> F,
) {
for constraint in constraints {
*windmill_common::min_version::MIN_VERSION.write().await = constraint.version().clone();
test().await;
}
}
use futures::StreamExt;
pub async fn assert_lockfile(
db: &Pool<Postgres>,
script_content: String,
language: ScriptLang,
expected_lines: Vec<&str>,
) -> anyhow::Result<()> {
initialize_tracing().await;
let server = ApiServer::start(db.clone()).await?;
let port = server.addr.port();
let client = windmill_api_client::create_client(
&format!("http://localhost:{port}"),
"SECRET_TOKEN".to_string(),
);
client
.create_script(
"test-workspace",
&NewScript {
language: windmill_api_client::types::ScriptLang::from_str(language.as_str())
.unwrap(),
content: script_content,
path: "f/system/test_import".to_string(),
concurrent_limit: None,
concurrency_time_window_s: None,
cache_ttl: None,
dedicated_worker: None,
description: "".to_string(),
draft_only: None,
envs: vec![],
is_template: None,
kind: None,
parent_hash: None,
lock: None,
summary: "".to_string(),
tag: None,
schema: std::collections::HashMap::new(),
ws_error_handler_muted: Some(false),
priority: None,
delete_after_use: None,
timeout: None,
restart_unless_cancelled: None,
deployment_message: None,
concurrency_key: None,
visible_to_runner_only: None,
no_main_func: None,
codebase: None,
has_preprocessor: None,
on_behalf_of_email: None,
assets: vec![],
},
)
.await
.unwrap();
let mut completed = listen_for_completed_jobs(db).await;
let db2 = db.clone();
in_test_worker(
db,
async move {
completed.next().await; // deployed script
let script = sqlx::query!(
"SELECT hash FROM script WHERE path = $1",
"f/system/test_import".to_string()
)
.fetch_one(&db2)
.await
.unwrap();
let job = RunJob::from(JobPayload::Dependencies {
path: "f/system/test_import".to_string(),
hash: ScriptHash(script.hash),
dedicated_worker: None,
language,
debouncing_settings: Default::default(),
})
.push(&db2)
.await;
completed.next().await; // completed job
let result = completed_job(job, &db2).await.json_result().unwrap();
assert_eq!(
result,
json!({
"lock": expected_lines.join("\n"),
"status": "Successful lock file generation"
})
);
},
port,
)
.await;
Ok(())
}
pub async fn run_deployed_relative_imports(
db: &Pool<Postgres>,
script_content: String,
language: ScriptLang,
) -> anyhow::Result<()> {
initialize_tracing().await;
let server = ApiServer::start(db.clone()).await?;
let port = server.addr.port();
let client = windmill_api_client::create_client(
&format!("http://localhost:{port}"),
"SECRET_TOKEN".to_string(),
);
client
.create_script(
"test-workspace",
&NewScript {
language: windmill_api_client::types::ScriptLang::from_str(language.as_str())
.unwrap(),
content: script_content,
path: "f/system/test_import".to_string(),
concurrent_limit: None,
concurrency_time_window_s: None,
cache_ttl: None,
dedicated_worker: None,
description: "".to_string(),
draft_only: None,
envs: vec![],
is_template: None,
kind: None,
parent_hash: None,
lock: None,
summary: "".to_string(),
tag: None,
schema: std::collections::HashMap::new(),
ws_error_handler_muted: Some(false),
priority: None,
delete_after_use: None,
timeout: None,
restart_unless_cancelled: None,
deployment_message: None,
concurrency_key: None,
visible_to_runner_only: None,
no_main_func: None,
codebase: None,
has_preprocessor: None,
on_behalf_of_email: None,
assets: vec![],
},
)
.await
.unwrap();
let mut completed = listen_for_completed_jobs(db).await;
let db2 = db.clone();
in_test_worker(
db,
async move {
completed.next().await; // deployed script
let script = sqlx::query!(
"SELECT hash FROM script WHERE path = $1",
"f/system/test_import".to_string()
)
.fetch_one(&db2)
.await
.unwrap();
let job = RunJob::from(JobPayload::ScriptHash {
path: "f/system/test_import".to_string(),
hash: ScriptHash(script.hash),
cache_ttl: None,
cache_ignore_s3_path: None,
dedicated_worker: None,
language,
priority: None,
apply_preprocessor: false,
concurrency_settings:
windmill_common::runnable_settings::ConcurrencySettings::default(),
debouncing_settings:
windmill_common::runnable_settings::DebouncingSettings::default(),
})
.push(&db2)
.await;
completed.next().await; // completed job
let result = completed_job(job, &db2).await.json_result().unwrap();
assert_eq!(
result,
serde_json::json!([
"f/system/same_folder_script",
"f/system/same_folder_script",
"f/system_relative/different_folder_script",
"f/system_relative/different_folder_script"
])
);
},
port,
)
.await;
Ok(())
}
pub async fn run_preview_relative_imports(
db: &Pool<Postgres>,
script_content: String,
language: ScriptLang,
) -> anyhow::Result<()> {
initialize_tracing().await;
let server = ApiServer::start(db.clone()).await?;
let port = server.addr.port();
let mut completed = listen_for_completed_jobs(db).await;
let db2 = db.clone();
in_test_worker(
db.clone(),
async move {
let job = RunJob::from(JobPayload::Code(RawCode {
hash: None,
content: script_content,
path: Some("f/system/test_import".to_string()),
language,
lock: None,
cache_ttl: None,
cache_ignore_s3_path: None,
dedicated_worker: None,
concurrency_settings:
windmill_common::runnable_settings::ConcurrencySettings::default().into(),
debouncing_settings:
windmill_common::runnable_settings::DebouncingSettings::default(),
}))
.push(&db2)
.await;
completed.next().await; // completed job
let result = completed_job(job, &db2).await.json_result().unwrap();
assert_eq!(
result,
serde_json::json!([
"f/system/same_folder_script",
"f/system/same_folder_script",
"f/system_relative/different_folder_script",
"f/system_relative/different_folder_script"
])
);
},
port,
)
.await;
Ok(())
}
#[cfg(all(feature = "private", feature = "agent_worker_server"))]
pub async fn testing_http_connection(port: u16) -> Connection {
let suffix = windmill_common::utils::create_default_worker_suffix("test-agent-worker");
let agent_token = format!(
"{}{}",
windmill_common::agent_workers::AGENT_JWT_PREFIX,
windmill_common::jwt::encode_with_internal_secret(windmill_api_agent_workers::AgentAuth {
worker_group: "testing-agent".to_owned(),
suffix: Some(suffix.clone()),
tags: vec!["flow".into(), "python3".into(), "dependency".into()],
exp: Some(usize::MAX),
})
.await
.expect("JWT token to be created")
);
let base_internal_url = format!("http://localhost:{port}");
Connection::Http(windmill_common::agent_workers::build_agent_http_client(
&suffix,
&agent_token,
&base_internal_url,
))
}
/// IMPORTANT!:
/// Do not run parallel in tests!
///
/// No tests can run this at the same time, will result into conflicts!!!
pub async fn rebuild_dmap(client: &windmill_api_client::Client) -> bool {
client
.client()
.post(format!(
"{}/w/test-workspace/workspaces/rebuild_dependency_map",
client.baseurl()
))
.send()
.await
.unwrap()
.status()
.is_success()
}
// ============================================================================
// Dedicated Worker Protocol Helpers
// ============================================================================
/// Result from parsing a dedicated worker stdout line
#[derive(Debug, Clone, PartialEq)]
pub enum DedicatedWorkerResult {
/// Worker printed "start" indicating it's ready
Start,
/// Worker returned a successful result
Success(serde_json::Value),
/// Worker returned an error result
Error(serde_json::Value),
/// Line is not a protocol message (e.g., logs)
Other(String),
}
/// Parse a line from dedicated worker stdout according to the protocol:
/// - "start" -> Ready signal
/// - "wm_res[success]:JSON" -> Success with result
/// - "wm_res[error]:JSON" -> Error with details
/// - anything else -> Other (logs)
pub fn parse_dedicated_worker_line(line: &str) -> DedicatedWorkerResult {
if line == "start" {
return DedicatedWorkerResult::Start;
}
if let Some(json_str) = line.strip_prefix("wm_res[success]:") {
match serde_json::from_str(json_str) {
Ok(value) => return DedicatedWorkerResult::Success(value),
Err(_) => return DedicatedWorkerResult::Other(line.to_string()),
}
}
if let Some(json_str) = line.strip_prefix("wm_res[error]:") {
match serde_json::from_str(json_str) {
Ok(value) => return DedicatedWorkerResult::Error(value),
Err(_) => return DedicatedWorkerResult::Other(line.to_string()),
}
}
DedicatedWorkerResult::Other(line.to_string())
}