* feat: mask sensitive values (secrets, password args) in job logs Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * test: replace artificial unit tests with real integration tests Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * test: consolidate into single comprehensive masking test covering 8 scenarios Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * feat: show first 3 chars of masked secrets and add security notice Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: update masking notice to say "display full value" Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: handle poisoned locks, deduplicate notice, mask non-string encrypted args Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * perf: snapshot-based masking, one lock per batch instead of per line Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * perf: use Aho-Corasick for O(m) single-pass matching regardless of secret count Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: track notice in snapshot (no global lock), document snapshot race trade-off Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1038 lines
38 KiB
Rust
1038 lines
38 KiB
Rust
use futures::Future;
|
|
|
|
#[cfg(any(target_os = "linux", target_os = "macos"))]
|
|
use nix::sys::signal::{self, Signal};
|
|
#[cfg(any(target_os = "linux", target_os = "macos"))]
|
|
use nix::unistd::Pid;
|
|
use process_wrap::tokio::TokioChildWrapper;
|
|
use windmill_common::agent_workers::PingJobStatusResponse;
|
|
use windmill_common::jobs::LARGE_LOG_THRESHOLD_SIZE;
|
|
use windmill_common::result_stream::extract_stream_from_logs;
|
|
|
|
#[cfg(windows)]
|
|
use std::process::Stdio;
|
|
use tokio::fs::File;
|
|
#[cfg(windows)]
|
|
use tokio::process::Command;
|
|
use windmill_common::error::to_anyhow;
|
|
|
|
use windmill_common::error::{self, Error};
|
|
|
|
use windmill_common::worker::{
|
|
get_windmill_memory_usage, get_worker_memory_usage, set_job_cancelled_query, Connection,
|
|
JobCancelled, CLOUD_HOSTED,
|
|
};
|
|
|
|
use windmill_queue::{append_logs, CanceledBy};
|
|
|
|
#[cfg(any(target_os = "linux", target_os = "macos"))]
|
|
use std::os::unix::process::ExitStatusExt;
|
|
|
|
use std::process::ExitStatus;
|
|
use std::sync::atomic::{AtomicBool, AtomicI32, AtomicU32, Ordering};
|
|
use std::sync::Arc;
|
|
use std::{io, panic, time::Duration};
|
|
|
|
use tracing::{trace_span, Instrument};
|
|
use uuid::Uuid;
|
|
|
|
use windmill_common::job_metrics;
|
|
|
|
#[cfg(target_os = "linux")]
|
|
use tokio::io::AsyncWriteExt;
|
|
use tokio::{
|
|
io::{AsyncBufReadExt, BufReader},
|
|
sync::{broadcast, watch},
|
|
time::{interval, sleep, Instant, MissedTickBehavior},
|
|
};
|
|
|
|
use futures::{
|
|
future::{self, ready, FutureExt},
|
|
stream, StreamExt,
|
|
};
|
|
|
|
use crate::common::{resolve_job_timeout, OccupancyMetrics, StreamNotifier};
|
|
use crate::job_logger::{append_job_logs, append_result_stream, append_with_limit};
|
|
use crate::job_logger_oss::process_streaming_log_lines;
|
|
use crate::worker_utils::{ping_job_status, update_worker_ping_from_job};
|
|
use crate::{MAX_RESULT_SIZE, MAX_WAIT_FOR_SIGINT, MAX_WAIT_FOR_SIGTERM};
|
|
|
|
use windmill_common::tracing_init::{QUIET_MODE, VERBOSE_TARGET};
|
|
|
|
lazy_static::lazy_static! {
|
|
pub static ref SLOW_LOGS: bool = std::env::var("SLOW_LOGS").ok().is_some_and(|x| x == "1" || x == "true");
|
|
pub static ref OTEL_JOB_LOGS: bool = std::env::var("OTEL_JOB_LOGS").ok().is_some_and(|x| x == "1" || x == "true");
|
|
}
|
|
|
|
// - kill windows process along with all child processes
|
|
#[cfg(windows)]
|
|
async fn kill_process_tree(pid: Option<u32>) -> Result<(), String> {
|
|
let pid = match pid {
|
|
Some(pid) => pid,
|
|
None => return Err("No PID provided to kill.".to_string()),
|
|
};
|
|
|
|
let output = Command::new("cmd")
|
|
.args(&["/C", "taskkill", "/PID", &pid.to_string(), "/T", "/F"])
|
|
.stdout(Stdio::piped())
|
|
.stderr(Stdio::piped())
|
|
.output()
|
|
.await
|
|
.map_err(|e| format!("Failed to execute taskkill: {}", e))?;
|
|
|
|
if output.status.success() {
|
|
Ok(())
|
|
} else {
|
|
Err(format!(
|
|
"Failed to kill process tree. Error: {}",
|
|
String::from_utf8_lossy(&output.stderr)
|
|
))
|
|
}
|
|
}
|
|
|
|
pub struct HandleChildResult {
|
|
pub result_stream: Option<String>,
|
|
}
|
|
|
|
/// - wait until child exits and return with exit status
|
|
/// - read lines from stdout and stderr and append them to the "queue"."logs"
|
|
/// quitting early if output exceedes MAX_LOG_SIZE characters (not bytes)
|
|
/// - update the `last_line` and `logs` strings with the program output
|
|
/// - update "queue"."last_ping" every five seconds
|
|
/// - kill process if we exceed timeout or "queue"."canceled" is set
|
|
#[tracing::instrument(name="run_subprocess", level = "info", skip_all, fields(otel.name = %child_name))]
|
|
pub async fn handle_child(
|
|
job_id: &Uuid,
|
|
conn: &Connection,
|
|
mem_peak: &mut i32,
|
|
canceled_by_ref: &mut Option<CanceledBy>,
|
|
mut child: Box<dyn TokioChildWrapper>,
|
|
nsjail: bool,
|
|
worker: &str,
|
|
w_id: &str,
|
|
child_name: &str,
|
|
custom_timeout: Option<i32>,
|
|
sigterm: bool,
|
|
occupancy_metrics: &mut Option<&mut OccupancyMetrics>,
|
|
// Do not print logs to output, but instead save to string.
|
|
pipe_stdout: Option<&mut String>,
|
|
stream_notifier: Option<StreamNotifier>,
|
|
) -> error::Result<HandleChildResult> {
|
|
let start = Instant::now();
|
|
|
|
#[cfg(all(feature = "private", feature = "enterprise"))]
|
|
if crate::OTEL_TRACING_PROXY_SETTINGS.read().await.enabled {
|
|
crate::otel_tracing_proxy_ee::set_current_job_context(*job_id).await;
|
|
}
|
|
|
|
let pid = child.id();
|
|
#[cfg(target_os = "linux")]
|
|
if let Some(pid) = pid {
|
|
//set the highest oom priority so OOM killer targets this job, not the worker
|
|
match File::create(format!("/proc/{pid}/oom_score_adj")).await {
|
|
Ok(mut file) => {
|
|
if let Err(e) = file.write_all(b"1000").await {
|
|
tracing::error!("Failed to write oom_score_adj for pid {pid}: {e:#}");
|
|
}
|
|
if let Err(e) = file.sync_all().await {
|
|
tracing::warn!("Failed to sync oom_score_adj for pid {pid}: {e:#}");
|
|
}
|
|
}
|
|
Err(e) => {
|
|
tracing::error!(
|
|
"Could not open /proc/{pid}/oom_score_adj: {e:#}. \
|
|
OOM killer may target the worker instead of this job"
|
|
);
|
|
}
|
|
}
|
|
} else {
|
|
tracing::info!("could not get child pid");
|
|
}
|
|
let (mut set_too_many_logs, mut too_many_logs) = watch::channel::<bool>(false);
|
|
let (tx, rx) = broadcast::channel::<()>(3);
|
|
let mut rx2: broadcast::Receiver<()> = tx.subscribe();
|
|
|
|
let output = child_joined_output_stream(&mut child, job_id.clone(), w_id.to_string());
|
|
|
|
let job_id: Uuid = job_id.clone();
|
|
|
|
/* the cancellation future is polled on by `wait_on_child` while
|
|
* waiting for the child to exit normally */
|
|
let update_job = update_job_poller(
|
|
job_id,
|
|
conn,
|
|
mem_peak,
|
|
canceled_by_ref,
|
|
Box::pin(stream::unfold((), move |_| async move {
|
|
Some((get_mem_peak(pid, nsjail).await, ()))
|
|
})),
|
|
worker,
|
|
w_id,
|
|
rx,
|
|
occupancy_metrics,
|
|
);
|
|
|
|
enum KillReason {
|
|
TooManyLogs,
|
|
Timeout { is_job_specific: bool },
|
|
Cancelled(Option<CanceledBy>),
|
|
AlreadyCompleted,
|
|
}
|
|
|
|
impl std::fmt::Debug for KillReason {
|
|
fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
|
|
match self {
|
|
KillReason::TooManyLogs => f.write_str("too many logs (max size: 2MB)"),
|
|
KillReason::Timeout { is_job_specific } => f.write_str(if *is_job_specific {
|
|
"timeout after exceeding job-specific duration limit"
|
|
} else {
|
|
"timeout after exceeding instance-wide job duration limit"
|
|
}),
|
|
KillReason::Cancelled(canceled_by) => {
|
|
let mut reason = "cancelled".to_string();
|
|
if let Some(canceled_by) = canceled_by {
|
|
if let Some(by) = canceled_by.username.as_ref() {
|
|
reason.push_str(&format!(" by {}", by));
|
|
}
|
|
if let Some(rsn) = canceled_by.reason.as_ref() {
|
|
reason.push_str(&format!(" (reason: {})", rsn));
|
|
}
|
|
}
|
|
f.write_str(&reason)
|
|
}
|
|
KillReason::AlreadyCompleted => f.write_str("already completed"),
|
|
}
|
|
}
|
|
}
|
|
|
|
let (timeout_duration, timeout_warn_msg, is_job_specific) =
|
|
resolve_job_timeout(&conn, w_id, job_id, custom_timeout).await;
|
|
if let Some(msg) = timeout_warn_msg {
|
|
append_logs(&job_id, w_id, msg.as_str(), conn).await;
|
|
}
|
|
|
|
/* a future that completes when the child process exits */
|
|
let wait_on_child = async {
|
|
let kill_reason = tokio::select! {
|
|
biased;
|
|
result = Box::into_pin(child.wait()) => return result.map(Ok),
|
|
Ok(()) = too_many_logs.changed() => KillReason::TooManyLogs,
|
|
_ = sleep(timeout_duration) => KillReason::Timeout { is_job_specific },
|
|
ex = update_job, if job_id != Uuid::nil() => match ex {
|
|
UpdateJobPollingExit::Done(canceled_by) => KillReason::Cancelled(canceled_by),
|
|
UpdateJobPollingExit::AlreadyCompleted => KillReason::AlreadyCompleted,
|
|
},
|
|
};
|
|
tx.send(()).expect("rx should never be dropped");
|
|
drop(tx);
|
|
|
|
let set_reason = async {
|
|
if matches!(kill_reason, KillReason::Timeout { .. }) {
|
|
match conn {
|
|
Connection::Sql(db) => {
|
|
if let Err(err) = set_job_cancelled_query(
|
|
job_id,
|
|
db,
|
|
"timeout",
|
|
&format!("duration > {}", timeout_duration.as_secs()),
|
|
)
|
|
.await
|
|
{
|
|
tracing::error!(%job_id, %err, "error setting cancelation reason for job {job_id}: {err}");
|
|
}
|
|
}
|
|
Connection::Http(client) => {
|
|
if let Err(err) = client
|
|
.post::<_, ()>(
|
|
&format!("/api/agent_workers/set_job_cancelled/{}", job_id),
|
|
None,
|
|
&JobCancelled {
|
|
canceled_by: "timeout".to_string(),
|
|
reason: format!("duration > {}", timeout_duration.as_secs()),
|
|
},
|
|
)
|
|
.await
|
|
{
|
|
tracing::error!(%job_id, %err, "error setting cancelation reason for job using http {job_id}: {err}");
|
|
}
|
|
}
|
|
}
|
|
}
|
|
};
|
|
|
|
#[allow(unused_variables)]
|
|
if let Some(id) = child.id() {
|
|
if *MAX_WAIT_FOR_SIGINT > 0 {
|
|
#[cfg(any(target_os = "linux", target_os = "macos"))]
|
|
signal::kill(Pid::from_raw(id as i32), Signal::SIGINT).unwrap();
|
|
|
|
for _ in 0..*MAX_WAIT_FOR_SIGINT {
|
|
if child.try_wait().is_ok_and(|x| x.is_some()) {
|
|
break;
|
|
}
|
|
sleep(Duration::from_secs(1)).await;
|
|
}
|
|
if child.try_wait().is_ok_and(|x| x.is_some()) {
|
|
set_reason.await;
|
|
return Ok(Err(kill_reason));
|
|
}
|
|
}
|
|
if sigterm {
|
|
#[cfg(any(target_os = "linux", target_os = "macos"))]
|
|
signal::kill(Pid::from_raw(id as i32), Signal::SIGTERM).unwrap();
|
|
|
|
for _ in 0..*MAX_WAIT_FOR_SIGTERM {
|
|
if child.try_wait().is_ok_and(|x| x.is_some()) {
|
|
break;
|
|
}
|
|
sleep(Duration::from_secs(1)).await;
|
|
}
|
|
if child.try_wait().is_ok_and(|x| x.is_some()) {
|
|
set_reason.await;
|
|
return Ok(Err(kill_reason));
|
|
}
|
|
}
|
|
}
|
|
#[cfg(windows)]
|
|
{
|
|
let pid_to_kill = child.id();
|
|
match kill_process_tree(pid_to_kill).await {
|
|
Ok(_) => tracing::debug!(
|
|
"successfully killed process tree with PID: {:?}",
|
|
pid_to_kill
|
|
),
|
|
Err(e) => tracing::error!("failed to kill process tree: {:?}", e),
|
|
};
|
|
set_reason.await;
|
|
return Ok(Err(kill_reason));
|
|
}
|
|
|
|
#[cfg(unix)]
|
|
{
|
|
/* send SIGKILL and reap child process */
|
|
let (_, kill) = future::join(set_reason, Box::into_pin(child.kill())).await;
|
|
kill.map(|()| Err(kill_reason))
|
|
}
|
|
};
|
|
|
|
let mut stream_result = Vec::new();
|
|
/* a future that reads output from the child and appends to the database */
|
|
let lines = write_lines(
|
|
output,
|
|
&job_id,
|
|
w_id,
|
|
worker,
|
|
conn,
|
|
&mut set_too_many_logs,
|
|
start,
|
|
pipe_stdout,
|
|
&mut rx2,
|
|
child_name,
|
|
&mut stream_result,
|
|
stream_notifier,
|
|
)
|
|
.instrument(trace_span!("child_lines"));
|
|
|
|
let (wait_result, _) = tokio::join!(wait_on_child, lines);
|
|
|
|
let success = wait_result.is_ok()
|
|
&& wait_result.as_ref().unwrap().is_ok()
|
|
&& wait_result.as_ref().unwrap().as_ref().unwrap().success();
|
|
tracing::info!(%job_id, %success, %mem_peak, %worker, "child process '{child_name}' took {}ms", start.elapsed().as_millis());
|
|
|
|
match wait_result {
|
|
_ if *too_many_logs.borrow() => Err(Error::ExecutionErr(format!(
|
|
"logs or result reached limit. (current max size: {MAX_RESULT_SIZE} characters)"
|
|
))),
|
|
Ok(Ok(status)) => process_status(&child_name, status, stream_result),
|
|
Ok(Err(kill_reason)) => match kill_reason {
|
|
KillReason::AlreadyCompleted => {
|
|
Err(Error::AlreadyCompleted("Job already completed".to_string()))
|
|
}
|
|
_ => Err(Error::ExecutionErr(format!(
|
|
"job process terminated due to {kill_reason:#?}"
|
|
))),
|
|
},
|
|
Err(err) => Err(Error::ExecutionErr(format!("job process io error: {err}"))),
|
|
}
|
|
}
|
|
|
|
pub const OTEL_PREFIX: &str = "OTEL: ";
|
|
pub const WAC_STEP_PREFIX: &str = "WM_WAC_STEP: ";
|
|
|
|
pub async fn write_lines(
|
|
output: impl stream::Stream<Item = io::Result<String>> + Send,
|
|
job_id: &Uuid,
|
|
w_id: &str,
|
|
worker: &str,
|
|
conn: &Connection,
|
|
set_too_many_logs: &mut watch::Sender<bool>,
|
|
start: Instant,
|
|
pipe_stdout: Option<&mut String>,
|
|
rx2: &mut broadcast::Receiver<()>,
|
|
child_name: &str,
|
|
stream_result: &mut Vec<String>,
|
|
stream_notifier: Option<StreamNotifier>,
|
|
) {
|
|
let max_log_size = if *CLOUD_HOSTED {
|
|
MAX_RESULT_SIZE
|
|
} else {
|
|
usize::MAX
|
|
};
|
|
|
|
/* log_remaining is zero when output limit was reached */
|
|
let mut log_remaining = if *CLOUD_HOSTED {
|
|
max_log_size
|
|
} else {
|
|
usize::MAX
|
|
};
|
|
let mut result = io::Result::Ok(());
|
|
let mut output = output
|
|
.take_until(async {
|
|
let _ = rx2.recv().await;
|
|
//wait at most 50ms after end of a script for output stream to end
|
|
tokio::time::sleep(Duration::from_millis(50)).await;
|
|
})
|
|
.boxed();
|
|
/* `do_write` resolves the task, but does not contain the Result.
|
|
* It's useful to know if the task completed. */
|
|
let (mut do_write, mut write_result) = tokio::spawn(ready(())).remote_handle();
|
|
|
|
let mut log_total_size: u64 = 0;
|
|
let pg_log_total_size = Arc::new(AtomicU32::new(0));
|
|
|
|
let mut pipe_stdout = pipe_stdout;
|
|
|
|
let is_stream = Arc::new(AtomicBool::new(false));
|
|
let offset = Arc::new(AtomicI32::new(0));
|
|
while let Some(line) = output.by_ref().next().await {
|
|
let do_write_ = do_write.shared();
|
|
|
|
let delay = if start.elapsed() < Duration::from_secs(10) {
|
|
Duration::from_millis(500)
|
|
} else if start.elapsed() < Duration::from_secs(60) {
|
|
Duration::from_millis(2500)
|
|
} else {
|
|
Duration::from_millis(5000)
|
|
};
|
|
|
|
let delay = if *SLOW_LOGS { delay * 10 } else { delay };
|
|
|
|
let mut read_lines = stream::once(async { line })
|
|
.chain(output.by_ref())
|
|
/* after receiving a line, continue until some delay has passed
|
|
* _and_ the previous database write is complete */
|
|
.take_until(future::join(sleep(delay), do_write_.clone()))
|
|
.boxed();
|
|
|
|
/* Read up until an error is encountered,
|
|
* handle log lines first and then the error... */
|
|
let mut joined = String::new();
|
|
|
|
let job_id = job_id.clone();
|
|
let mut nstream = String::new();
|
|
|
|
// Snapshot secrets once per batch — no lock needed per line.
|
|
// Trade-off: secrets registered mid-batch (between snapshot and log line)
|
|
// won't be masked until the next batch. In practice the async HTTP round-trip
|
|
// to fetch a secret completes before the script's log line arrives.
|
|
let mask_snapshot = windmill_common::sensitive_log_masks::snapshot(&job_id);
|
|
|
|
while let Some(line) = read_lines.next().await {
|
|
match line {
|
|
Ok(line) => {
|
|
if line.is_empty() {
|
|
continue;
|
|
}
|
|
let line = if let Some(ref snap) = mask_snapshot {
|
|
match snap.mask(&line) {
|
|
std::borrow::Cow::Owned(masked) => masked,
|
|
std::borrow::Cow::Borrowed(_) => line,
|
|
}
|
|
} else {
|
|
line
|
|
};
|
|
if *OTEL_JOB_LOGS {
|
|
if let Some(otel_suffix) = line.strip_prefix(OTEL_PREFIX) {
|
|
tracing::event!(tracing::Level::INFO, otel_suffix);
|
|
}
|
|
}
|
|
if let Some(step_json) = line.strip_prefix(WAC_STEP_PREFIX) {
|
|
// Real-time WAC step start marker — fire-and-forget DB write
|
|
let conn = conn.clone();
|
|
let job_id = job_id.clone();
|
|
let step_json = step_json.to_string();
|
|
tokio::spawn(async move {
|
|
if let Err(e) = handle_wac_step_marker(&conn, &job_id, &step_json).await
|
|
{
|
|
tracing::warn!(%job_id, "Failed to write WAC step marker: {e}");
|
|
}
|
|
});
|
|
continue;
|
|
}
|
|
if let Some(stream) = extract_stream_from_logs(&line) {
|
|
let len = stream.len();
|
|
if log_remaining >= len {
|
|
log_remaining -= len;
|
|
nstream.push_str(&stream);
|
|
stream_result.push(stream);
|
|
} else {
|
|
log_remaining = 0;
|
|
}
|
|
} else {
|
|
append_with_limit(&mut joined, &line, &mut log_remaining);
|
|
}
|
|
if log_remaining == 0 {
|
|
tracing::info!(%job_id, "Too many logs lines for job {job_id}");
|
|
let _ = set_too_many_logs.send(true);
|
|
joined.push_str(&format!(
|
|
"Job logs or result reached character limit of {MAX_RESULT_SIZE}; killing job."
|
|
));
|
|
/* stop reading and drop our streams fairly quickly */
|
|
break;
|
|
}
|
|
}
|
|
Err(err) => {
|
|
result = Err(err);
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
|
|
/* Ensure the last flush completed before starting a new one.
|
|
*
|
|
* This shouldn't pause since `take_until()` reads lines until `do_write`
|
|
* resolves. We only stop reading lines before `take_until()` resolves if we reach
|
|
* EOF or a read error. In those cases, waiting on a database query to complete is
|
|
* fine because we're done. */
|
|
|
|
if let Some(Ok(p)) = do_write_
|
|
.then(|()| write_result)
|
|
.await
|
|
.err()
|
|
.map(|err| err.try_into_panic())
|
|
{
|
|
panic::resume_unwind(p);
|
|
}
|
|
|
|
let joined_len = joined.len() as u64;
|
|
log_total_size += joined_len;
|
|
let compact_logs = log_total_size > LARGE_LOG_THRESHOLD_SIZE as u64;
|
|
if compact_logs {
|
|
log_total_size = 0;
|
|
}
|
|
|
|
let worker_name = worker.to_string();
|
|
|
|
if let Some(buf) = &mut pipe_stdout {
|
|
buf.push_str(&joined);
|
|
(do_write, write_result) = tokio::spawn(async {}).remote_handle();
|
|
} else {
|
|
let conn = conn.clone();
|
|
let worker_name = worker_name.to_string();
|
|
let w_id = w_id.to_string();
|
|
let job_id = job_id.clone();
|
|
let pg_log_total_size = pg_log_total_size.clone();
|
|
let stream_notifier = stream_notifier.clone();
|
|
let is_stream = is_stream.clone();
|
|
let offset = offset.clone();
|
|
(do_write, write_result) = tokio::spawn(async move {
|
|
if !nstream.is_empty() {
|
|
if let Some(stream_notifier) = stream_notifier {
|
|
if !is_stream.load(Ordering::SeqCst) {
|
|
is_stream.store(true, Ordering::SeqCst);
|
|
stream_notifier.update_flow_status_with_stream_job();
|
|
}
|
|
};
|
|
|
|
if let Err(err) = append_result_stream(
|
|
&conn,
|
|
&w_id,
|
|
&job_id,
|
|
&nstream,
|
|
offset.fetch_add(1, Ordering::SeqCst),
|
|
)
|
|
.await
|
|
{
|
|
tracing::error!(
|
|
"Unable to send result stream for job {job_id}. Error was: {:?}",
|
|
err
|
|
);
|
|
}
|
|
}
|
|
append_job_logs(
|
|
&job_id,
|
|
&w_id,
|
|
&joined,
|
|
&conn,
|
|
compact_logs,
|
|
pg_log_total_size,
|
|
&worker_name,
|
|
)
|
|
.await;
|
|
})
|
|
.remote_handle();
|
|
}
|
|
|
|
if let Err(err) = result {
|
|
tracing::error!(%job_id, %err, "error reading output for job {job_id} '{child_name}': {err}");
|
|
break;
|
|
}
|
|
|
|
if *set_too_many_logs.borrow() {
|
|
break;
|
|
}
|
|
}
|
|
|
|
/* drop our end of the pipe */
|
|
drop(output);
|
|
|
|
if let Some(Ok(p)) = do_write
|
|
.then(|()| write_result)
|
|
.await
|
|
.err()
|
|
.map(|err| err.try_into_panic())
|
|
{
|
|
panic::resume_unwind(p);
|
|
}
|
|
}
|
|
|
|
/// Handle a real-time WAC step start marker emitted via stdout.
|
|
/// Writes a timeline entry (with started_at but no duration_ms) to workflow_as_code_status
|
|
/// so the frontend can show the step immediately while it's still running.
|
|
async fn handle_wac_step_marker(
|
|
conn: &Connection,
|
|
job_id: &Uuid,
|
|
json_str: &str,
|
|
) -> error::Result<()> {
|
|
#[derive(serde::Deserialize)]
|
|
struct StepMarker {
|
|
key: String,
|
|
started_at: String,
|
|
}
|
|
let marker: StepMarker = serde_json::from_str(json_str).map_err(|e| {
|
|
error::Error::internal_err(format!("Failed to parse WM_WAC_STEP marker: {e}"))
|
|
})?;
|
|
|
|
let step_timeline_key = format!("_step/{}", marker.key);
|
|
let timeline_val = serde_json::json!({
|
|
"scheduled_for": marker.started_at,
|
|
"started_at": marker.started_at,
|
|
"name": marker.key,
|
|
});
|
|
|
|
match conn {
|
|
Connection::Sql(db) => {
|
|
sqlx::query(
|
|
"INSERT INTO v2_job_status (id, workflow_as_code_status)
|
|
VALUES ($1, jsonb_build_object($2, $3::jsonb))
|
|
ON CONFLICT (id) DO UPDATE SET
|
|
workflow_as_code_status = jsonb_set(
|
|
COALESCE(v2_job_status.workflow_as_code_status, '{}'::jsonb),
|
|
ARRAY[$2],
|
|
$3::jsonb
|
|
)",
|
|
)
|
|
.bind(job_id)
|
|
.bind(&step_timeline_key)
|
|
.bind(&timeline_val)
|
|
.execute(db)
|
|
.await
|
|
.map_err(|e| {
|
|
error::Error::internal_err(format!("DB error writing WAC step marker: {e}"))
|
|
})?;
|
|
}
|
|
Connection::Http(_) => {
|
|
// Agent workers don't support WAC v2 yet
|
|
}
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
pub(crate) async fn get_mem_peak(pid: Option<u32>, nsjail: bool) -> i32 {
|
|
if pid.is_none() {
|
|
return -1;
|
|
}
|
|
let pid = if nsjail {
|
|
// Read /proc/<nsjail_pid>/task/<nsjail_pid>/children and extract pid
|
|
let nsjail_pid = pid.unwrap();
|
|
let children_path = format!("/proc/{}/task/{}/children", nsjail_pid, nsjail_pid);
|
|
if let Ok(mut file) = File::open(children_path).await {
|
|
let mut contents = String::new();
|
|
if tokio::io::AsyncReadExt::read_to_string(&mut file, &mut contents)
|
|
.await
|
|
.is_ok()
|
|
{
|
|
if let Some(child_pid) = contents.split_whitespace().next() {
|
|
if let Ok(child_pid) = child_pid.parse::<u32>() {
|
|
child_pid
|
|
} else {
|
|
return -1;
|
|
}
|
|
} else {
|
|
return -1;
|
|
}
|
|
} else {
|
|
return -1;
|
|
}
|
|
} else {
|
|
return -1;
|
|
}
|
|
} else {
|
|
pid.unwrap()
|
|
};
|
|
|
|
if let Ok(file) = File::open(format!("/proc/{}/status", pid)).await {
|
|
let mut lines = BufReader::new(file).lines();
|
|
while let Some(line) = lines.next_line().await.unwrap_or(None) {
|
|
if line.starts_with("VmHWM:") {
|
|
return line
|
|
.split_whitespace()
|
|
.nth(1)
|
|
.and_then(|s| s.parse::<i32>().ok())
|
|
.unwrap_or(-1);
|
|
};
|
|
}
|
|
-2
|
|
} else {
|
|
// rand::random::<i32>() % 100 // to remove - used to fake memory data on MacOS
|
|
-3
|
|
}
|
|
}
|
|
|
|
pub async fn run_future_with_polling_update_job_poller<Fut, T, S>(
|
|
job_id: Uuid,
|
|
timeout: Option<i32>,
|
|
conn: &Connection,
|
|
mem_peak: &mut i32,
|
|
canceled_by_ref: &mut Option<CanceledBy>,
|
|
result_f: Fut,
|
|
worker_name: &str,
|
|
w_id: &str,
|
|
occupancy_metrics: &mut Option<&mut OccupancyMetrics>,
|
|
get_mem: S,
|
|
) -> error::Result<T>
|
|
where
|
|
Fut: Future<Output = windmill_common::error::Result<T>>,
|
|
S: stream::Stream<Item = i32> + Unpin,
|
|
{
|
|
let (tx, rx) = broadcast::channel::<()>(3);
|
|
|
|
let update_job = update_job_poller(
|
|
job_id,
|
|
conn,
|
|
mem_peak,
|
|
canceled_by_ref,
|
|
get_mem,
|
|
worker_name,
|
|
w_id,
|
|
rx,
|
|
occupancy_metrics,
|
|
);
|
|
|
|
let timeout_ms = u64::try_from(
|
|
resolve_job_timeout(&conn, &w_id, job_id, timeout)
|
|
.await
|
|
.0
|
|
.as_millis(),
|
|
)
|
|
.unwrap_or(200000);
|
|
|
|
let rows = tokio::select! {
|
|
biased;
|
|
result = tokio::time::timeout(std::time::Duration::from_millis(timeout_ms), result_f) => result
|
|
.map_err(|e| {
|
|
tracing::error!("Query timeout: {}", e);
|
|
Error::ExecutionErr(format!("Query timeout after (>{}s)", timeout_ms/1000))
|
|
})?,
|
|
ex = update_job, if job_id != Uuid::nil() => {
|
|
match ex {
|
|
UpdateJobPollingExit::Done(canceled_by) => {
|
|
let (by, reason) = canceled_by.as_ref().map_or(("unknown".to_string(), "unknown".to_string()), |x| (x.username.clone().unwrap_or("".to_string()), x.reason.clone().unwrap_or("".to_string())));
|
|
Err(Error::ExecutionErr(format!("Job cancelled by {by} (reason: {reason})",))).map_err(to_anyhow)?
|
|
},
|
|
UpdateJobPollingExit::AlreadyCompleted => Err(Error::AlreadyCompleted("Job already completed".to_string())).map_err(to_anyhow)?,
|
|
}
|
|
}
|
|
}?;
|
|
drop(tx);
|
|
Ok(rows)
|
|
}
|
|
|
|
/// Outcome of [`run_future_with_polling_update_job_poller_graceful`].
|
|
pub enum GracefulPollOutcome<T> {
|
|
/// The future completed normally.
|
|
Ok(T),
|
|
/// The job timed out.
|
|
Timeout(u64),
|
|
/// The job was cancelled and the future finished within the grace period.
|
|
Cancelled { canceled_by: Option<CanceledBy> },
|
|
/// The job was cancelled but the future did NOT finish within the grace period.
|
|
CancelledTimeout { canceled_by: Option<CanceledBy> },
|
|
/// The job was already moved to v2_job_completed externally.
|
|
AlreadyCompleted,
|
|
}
|
|
|
|
/// Like [`run_future_with_polling_update_job_poller`] but on cancellation, signals
|
|
/// `cancel_tx` and waits up to `grace_period` for the future to finish instead of
|
|
/// dropping it immediately. This lets in-flight work (e.g. AI tool calls) complete
|
|
/// and clean up properly.
|
|
pub async fn run_future_with_polling_update_job_poller_graceful<Fut, T, S>(
|
|
job_id: Uuid,
|
|
timeout: Option<i32>,
|
|
conn: &Connection,
|
|
mem_peak: &mut i32,
|
|
canceled_by_ref: &mut Option<CanceledBy>,
|
|
result_f: Fut,
|
|
worker_name: &str,
|
|
w_id: &str,
|
|
occupancy_metrics: &mut Option<&mut OccupancyMetrics>,
|
|
get_mem: S,
|
|
cancel_tx: watch::Sender<bool>,
|
|
grace_period: std::time::Duration,
|
|
) -> error::Result<GracefulPollOutcome<T>>
|
|
where
|
|
Fut: Future<Output = error::Result<T>>,
|
|
S: stream::Stream<Item = i32> + Unpin,
|
|
{
|
|
let (tx, rx) = broadcast::channel::<()>(3);
|
|
|
|
let mut update_job = Box::pin(update_job_poller(
|
|
job_id,
|
|
conn,
|
|
mem_peak,
|
|
canceled_by_ref,
|
|
get_mem,
|
|
worker_name,
|
|
w_id,
|
|
rx,
|
|
occupancy_metrics,
|
|
));
|
|
|
|
let timeout_ms = u64::try_from(
|
|
resolve_job_timeout(conn, w_id, job_id, timeout)
|
|
.await
|
|
.0
|
|
.as_millis(),
|
|
)
|
|
.unwrap_or(200_000);
|
|
|
|
let mut result_f = Box::pin(result_f);
|
|
|
|
let outcome = tokio::select! {
|
|
biased;
|
|
result = tokio::time::timeout(
|
|
std::time::Duration::from_millis(timeout_ms),
|
|
&mut result_f,
|
|
) => {
|
|
match result {
|
|
Ok(Ok(v)) => GracefulPollOutcome::Ok(v),
|
|
Ok(Err(e)) => return Err(e),
|
|
Err(_) => GracefulPollOutcome::Timeout(timeout_ms),
|
|
}
|
|
}
|
|
ex = &mut update_job, if job_id != Uuid::nil() => {
|
|
match ex {
|
|
UpdateJobPollingExit::Done(cb) => {
|
|
let _ = cancel_tx.send(true);
|
|
match tokio::time::timeout(grace_period, &mut result_f).await {
|
|
Ok(_) => GracefulPollOutcome::Cancelled { canceled_by: cb },
|
|
Err(_) => GracefulPollOutcome::CancelledTimeout { canceled_by: cb },
|
|
}
|
|
}
|
|
UpdateJobPollingExit::AlreadyCompleted => GracefulPollOutcome::AlreadyCompleted,
|
|
}
|
|
}
|
|
};
|
|
|
|
drop(tx);
|
|
Ok(outcome)
|
|
}
|
|
|
|
pub enum UpdateJobPollingExit {
|
|
Done(Option<CanceledBy>),
|
|
AlreadyCompleted,
|
|
}
|
|
|
|
pub async fn update_job_poller<S>(
|
|
job_id: Uuid,
|
|
conn: &Connection,
|
|
mem_peak: &mut i32,
|
|
canceled_by_ref: &mut Option<CanceledBy>,
|
|
mut get_mem: S,
|
|
worker_name: &str,
|
|
w_id: &str,
|
|
mut rx: broadcast::Receiver<()>,
|
|
occupancy_metrics: &mut Option<&mut OccupancyMetrics>,
|
|
) -> UpdateJobPollingExit
|
|
where
|
|
S: stream::Stream<Item = i32> + Unpin,
|
|
{
|
|
let update_job_interval = Duration::from_millis(500);
|
|
|
|
let conn = conn.clone();
|
|
let mut interval = interval(update_job_interval);
|
|
interval.set_missed_tick_behavior(MissedTickBehavior::Skip);
|
|
|
|
let mut i = 0;
|
|
|
|
let mut memory_metric_id: Result<String, Error> =
|
|
Err(Error::NotFound("not yet initialized".to_string()));
|
|
|
|
loop {
|
|
tokio::select!(
|
|
_ = rx.recv() => break,
|
|
_ = interval.tick() => {
|
|
// update the last_ping column every 5 seconds (or 50 seconds in quiet mode)
|
|
i+=1;
|
|
// In quiet mode, emit memory snapshot logs 10x less frequently
|
|
let memory_snapshot_interval = if *QUIET_MODE { 100 } else { 10 };
|
|
if i == 1 || i % memory_snapshot_interval == 0 {
|
|
let memory_usage = get_worker_memory_usage();
|
|
let wm_memory_usage = get_windmill_memory_usage();
|
|
tracing::info!("job {job_id} on {worker_name} in {w_id} worker memory snapshot {}kB/{}kB", memory_usage.unwrap_or_default()/1024, wm_memory_usage.unwrap_or_default()/1024);
|
|
let occupancy = occupancy_metrics.as_mut().map(|x| x.update_occupancy_metrics());
|
|
if job_id != Uuid::nil() {
|
|
if let Err(err) = update_worker_ping_from_job(&conn, &job_id, w_id, worker_name, memory_usage, wm_memory_usage, occupancy).await {
|
|
tracing::error!("Unable to update worker ping for job {} in workspace {}. Error was: {:?}", job_id, w_id, err);
|
|
}
|
|
}
|
|
}
|
|
let current_mem = get_mem.next().await.unwrap_or(0);
|
|
if current_mem > *mem_peak {
|
|
*mem_peak = current_mem
|
|
}
|
|
// In quiet mode, emit "still running" logs 10x less frequently
|
|
let still_running_interval = if *QUIET_MODE { 10 } else { 1 };
|
|
if i % still_running_interval == 0 {
|
|
tracing::info!("job {job_id} on {worker_name} in {w_id} still running. mem: {current_mem}kB, peak mem: {mem_peak}kB");
|
|
}
|
|
|
|
|
|
let update_job_row = i == 2 || (!*SLOW_LOGS && (i < 20 || (i < 120 && i % 5 == 0) || i % 10 == 0)) || i % 20 == 0;
|
|
if update_job_row && job_id != Uuid::nil() {
|
|
if let Connection::Sql(ref db) = conn {
|
|
// Only track memory when it's non-zero (avoids storing all-zero timeseries for jobs that don't report memory)
|
|
if current_mem > 0 {
|
|
// Register on first non-zero reading (deferred from i==2 to avoid metric for jobs with no memory reporting)
|
|
if memory_metric_id.is_err() {
|
|
memory_metric_id = job_metrics::register_metric_for_job(
|
|
&db,
|
|
w_id.to_string(),
|
|
job_id,
|
|
"memory_kb".to_string(),
|
|
job_metrics::MetricKind::TimeseriesInt,
|
|
Some("Job Memory Footprint (kB)".to_string()),
|
|
)
|
|
.await;
|
|
}
|
|
if let Ok(ref metric_id) = memory_metric_id {
|
|
if let Err(err) = job_metrics::record_timeseries_value(&db, w_id.to_string(), job_id, metric_id.to_owned(), job_metrics::MetricNumericValue::Integer(current_mem), job_metrics::MetricKind::TimeseriesInt).await {
|
|
tracing::error!("Unable to save memory stat for job {} in workspace {}. Error was: {:?}", job_id, w_id, err);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
if matches!(conn, Connection::Http(_)) {
|
|
if i % 4 != 0 {
|
|
// only ping every 4th time (2s) on http agent mode
|
|
continue;
|
|
}
|
|
}
|
|
let ping_job_status = ping_job_status(&conn, &job_id, Some(*mem_peak), if current_mem > 0 { Some(current_mem) } else { None }).await.unwrap_or_else(|e| {
|
|
tracing::error!("Unable to ping job status for job {job_id}. Error was: {:?}", e);
|
|
PingJobStatusResponse {
|
|
canceled_by: None,
|
|
canceled_reason: None,
|
|
already_completed: false,
|
|
}
|
|
});
|
|
if ping_job_status.already_completed {
|
|
return UpdateJobPollingExit::AlreadyCompleted
|
|
}
|
|
if ping_job_status.canceled_by.is_some() {
|
|
canceled_by_ref.replace(CanceledBy {
|
|
username: ping_job_status.canceled_by.clone(),
|
|
reason: ping_job_status.canceled_reason.clone(),
|
|
});
|
|
break
|
|
}
|
|
}
|
|
},
|
|
);
|
|
}
|
|
tracing::info!(target: VERBOSE_TARGET, "job {job_id} finished");
|
|
|
|
UpdateJobPollingExit::Done(canceled_by_ref.clone())
|
|
}
|
|
|
|
/// takes stdout and stderr from Child, panics if either are not present
|
|
///
|
|
/// builds a stream joining both stdout and stderr each read line by line
|
|
fn child_joined_output_stream(
|
|
child: &mut Box<dyn TokioChildWrapper>,
|
|
job_id: Uuid,
|
|
w_id: String,
|
|
) -> impl stream::FusedStream<Item = io::Result<String>> {
|
|
let stderr = child
|
|
.stderr()
|
|
.take()
|
|
.expect("child did not have a handle to stderr");
|
|
|
|
let stdout = child
|
|
.stdout()
|
|
.take()
|
|
.expect("child did not have a handle to stdout");
|
|
|
|
let stdout = BufReader::new(stdout).lines();
|
|
let stderr = BufReader::new(stderr).lines();
|
|
stream::select(
|
|
lines_to_stream(stderr, true, job_id.clone(), w_id.clone()),
|
|
lines_to_stream(stdout, false, job_id, w_id),
|
|
)
|
|
}
|
|
|
|
pub fn lines_to_stream<R: tokio::io::AsyncBufRead + Unpin>(
|
|
mut lines: tokio::io::Lines<R>,
|
|
stderr: bool,
|
|
job_id: Uuid,
|
|
w_id: String,
|
|
) -> impl futures::Stream<Item = io::Result<String>> {
|
|
stream::poll_fn(move |cx| {
|
|
std::pin::Pin::new(&mut lines)
|
|
.poll_next_line(cx)
|
|
.map(|result| process_streaming_log_lines(result, stderr, &job_id, &w_id))
|
|
})
|
|
}
|
|
|
|
pub fn process_status(
|
|
program: &str,
|
|
status: ExitStatus,
|
|
stream_result: Vec<String>,
|
|
) -> error::Result<HandleChildResult> {
|
|
if status.success() {
|
|
Ok(HandleChildResult {
|
|
result_stream: if stream_result.is_empty() {
|
|
None
|
|
} else {
|
|
Some(stream_result.join(""))
|
|
},
|
|
})
|
|
} else if let Some(code) = status.code() {
|
|
Err(error::Error::ExitStatus(program.to_string(), code))
|
|
} else {
|
|
#[cfg(any(target_os = "linux", target_os = "macos"))]
|
|
return Err(error::Error::ExecutionErr(format!(
|
|
"process terminated by signal: {:#?}, stopped_signal: {:#?}, core_dumped: {}",
|
|
status.signal(),
|
|
status.stopped_signal(),
|
|
status.core_dumped()
|
|
)));
|
|
|
|
#[cfg(not(any(target_os = "linux", target_os = "macos")))]
|
|
return Err(error::Error::ExecutionErr(String::from(
|
|
"process terminated by signal",
|
|
)));
|
|
}
|
|
}
|