mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-08-19 08:01:25 +00:00
aeaea57ca1
* fix(wac): hand a caught task and step failure the same shape in every round Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * refactor(wac): decide the failure record once, server-side Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(wac): leave a legacy SDK's failure marker untouched, and ship wacError to jsr Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(wac): carry a step's custom error fields, and bound the stack in bytes Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(wac): keep a step's extra fields serializable and bounded Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(wac): record a non-Error throw the way a task records it Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(wac): guard the last unguarded throw site in the step marker Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(wac): make failure reporting non-throwing on both clients Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(wac): take the step traceback the way the executor takes it Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(wac): contain the reads that happen before a failure is checkpointed Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(wac): fall back to the checkpointed marker, not the live one Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(wac): keep non-finite fields and hostile proxies out of the checkpoint path Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(wac): keep the snapshot that passed the serialization probe Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * chore(wac): keep the failure-record module's surface to what is used Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2320 lines
82 KiB
Rust
2320 lines
82 KiB
Rust
#[cfg(feature = "otel")]
|
|
use opentelemetry::trace::FutureExt;
|
|
|
|
use serde::{Deserialize, Serialize};
|
|
use sqlx::types::Json;
|
|
use std::{
|
|
collections::HashMap,
|
|
sync::{
|
|
atomic::{AtomicBool, AtomicU16, Ordering},
|
|
Arc,
|
|
},
|
|
};
|
|
use tracing::{field, Instrument};
|
|
#[cfg(not(feature = "otel"))]
|
|
use windmill_common::otel_oss::FutureExt;
|
|
|
|
use uuid::Uuid;
|
|
|
|
/// Set by the result processor when a WAC child completion makes suspend reach 0,
|
|
/// signaling the worker main loop to check for suspended jobs immediately.
|
|
pub static WAC_SUSPEND_READY: AtomicBool = AtomicBool::new(false);
|
|
|
|
use windmill_common::{
|
|
add_time,
|
|
error::{self, Error},
|
|
flow_status::FlowJobDuration,
|
|
jobs::JobKind,
|
|
utils::WarnAfterExt,
|
|
worker::{error_to_value, to_raw_value, Connection, WORKER_GROUP},
|
|
worker_group_job_stats::{accumulate_job_stats, flush_stats_to_db, JobStatsMap},
|
|
KillpillSender, DB,
|
|
};
|
|
|
|
#[cfg(feature = "benchmark")]
|
|
use windmill_common::bench::{BenchmarkInfo, BenchmarkIter};
|
|
|
|
use windmill_queue::{
|
|
append_logs, asset_dispatch, get_mini_completed_job, is_pre_shaped_wm_failure_result,
|
|
CanceledBy, FlowRunners, JobCompleted, MiniCompletedJob, MiniPulledJob, ValidableJson,
|
|
WrappedError, INIT_SCRIPT_TAG, MANUAL_FAILURE_ERROR_NAME,
|
|
};
|
|
|
|
use serde_json::{json, value::RawValue, Value};
|
|
|
|
use tokio::{sync::Notify, task::JoinHandle};
|
|
|
|
use windmill_queue::{add_completed_job, add_completed_job_error};
|
|
|
|
use crate::{
|
|
bash_executor::ANSI_ESCAPE_RE,
|
|
common::{read_result, save_in_cache},
|
|
otel_oss::add_root_flow_job_to_otlp,
|
|
worker_flow::update_flow_status_after_job_completion,
|
|
JobCompletedReceiver, JobCompletedSender, SameWorkerSender, SendResult, SendResultPayload,
|
|
UpdateFlow, SAME_WORKER_REQUIREMENTS,
|
|
};
|
|
use windmill_common::client::AuthedClient;
|
|
|
|
#[derive(Debug, Deserialize)]
|
|
struct ErrorMessage {
|
|
message: String,
|
|
name: String,
|
|
}
|
|
|
|
#[derive(Debug, Deserialize)]
|
|
struct NestedErrorMessage {
|
|
error: ErrorMessage,
|
|
}
|
|
|
|
/// Extract `{ name, message }` from a result. Accepts both the standard
|
|
/// top-level shape (regular runtime errors) and the nested `{ error: { name,
|
|
/// message }, ... }` shape produced by the wm_failure injection.
|
|
///
|
|
/// For wm_failure-injected results, we prefer the nested error: a successful
|
|
/// run may legitimately contain top-level `name`/`message` fields (user data
|
|
/// named `name`/`message`), and we want OTel to record the ManualFailure
|
|
/// rather than the user's sibling fields.
|
|
fn extract_error_message(raw: &str) -> Option<ErrorMessage> {
|
|
let nested = serde_json::from_str::<NestedErrorMessage>(raw)
|
|
.ok()
|
|
.map(|n| n.error);
|
|
if matches!(&nested, Some(em) if em.name == MANUAL_FAILURE_ERROR_NAME) {
|
|
return nested;
|
|
}
|
|
if let Ok(em) = serde_json::from_str::<ErrorMessage>(raw) {
|
|
return Some(em);
|
|
}
|
|
nested
|
|
}
|
|
|
|
/// Returns the post-processing `success` value (after any `wm_failure`
|
|
/// override). Callers use this to make worker-loop decisions that depend on
|
|
/// whether the job ultimately succeeded — e.g. the init-script killpill.
|
|
async fn process_jc(
|
|
mut jc: JobCompleted,
|
|
worker_name: &str,
|
|
base_internal_url: &str,
|
|
db: &DB,
|
|
worker_dir: &str,
|
|
same_worker_tx: Option<&SameWorkerSender>,
|
|
job_completed_sender: &JobCompletedSender,
|
|
stats_map: &JobStatsMap,
|
|
killpill_rx: &tokio::sync::broadcast::Receiver<()>,
|
|
#[cfg(feature = "benchmark")] bench: &mut BenchmarkIter,
|
|
#[cfg(feature = "benchmark")] bench_infos: &mut BenchmarkInfo,
|
|
) -> bool {
|
|
// Parse `wm_labels` and `wm_failure` together (single `from_str`)
|
|
// so we don't deserialize the whole result twice on every job.
|
|
let metadata = jc.result.result_metadata();
|
|
|
|
// If the script returned a `wm_failure: <string>` field in its
|
|
// result, tag the run as a failure. Inject an `error: { name, message }`
|
|
// at the top level so error handlers / UI / OTel see the standard error
|
|
// shape, while preserving sibling fields (`windmill_status_code`,
|
|
// `windmill_content_type`, `windmill_headers`, the user's data) at the
|
|
// top level so sync webhook responses still honor them.
|
|
if jc.success {
|
|
if let Some(failure_msg) = metadata.wm_failure.as_ref() {
|
|
if let Ok(Value::Object(mut map)) = serde_json::from_str::<Value>(jc.result.get()) {
|
|
map.insert(
|
|
"error".to_string(),
|
|
json!({ "name": MANUAL_FAILURE_ERROR_NAME, "message": failure_msg }),
|
|
);
|
|
if let Ok(raw) = serde_json::value::to_raw_value(&Value::Object(map)) {
|
|
jc.result = Arc::new(raw);
|
|
}
|
|
}
|
|
jc.success = false;
|
|
}
|
|
}
|
|
|
|
let success: bool = jc.success;
|
|
|
|
let span = if success {
|
|
tracing::span!(
|
|
tracing::Level::INFO,
|
|
"job_postprocessing",
|
|
job_id = %jc.job.id, root_job = field::Empty, workspace_id = %jc.job.workspace_id, worker = %worker_name,tag = %jc.job.tag,
|
|
// hostname = %hostname,
|
|
language = field::Empty,
|
|
script_path = field::Empty,
|
|
flow_step_id = field::Empty,
|
|
parent_job = field::Empty,
|
|
job_kind = %jc.job.kind.as_str(),
|
|
created_by = %jc.job.created_by,
|
|
trigger_kind = field::Empty,
|
|
trigger = field::Empty,
|
|
script_hash = field::Empty,
|
|
otel.name = field::Empty,
|
|
success = %success,
|
|
labels = field::Empty,
|
|
)
|
|
} else {
|
|
tracing::span!(
|
|
tracing::Level::INFO,
|
|
"job_postprocessing",
|
|
job_id = %jc.job.id, root_job = field::Empty, workspace_id = %jc.job.workspace_id, worker = %worker_name,tag = %jc.job.tag,
|
|
// hostname = %hostname,
|
|
language = field::Empty,
|
|
script_path = field::Empty,
|
|
flow_step_id = field::Empty,
|
|
parent_job = field::Empty,
|
|
job_kind = %jc.job.kind.as_str(),
|
|
created_by = %jc.job.created_by,
|
|
trigger_kind = field::Empty,
|
|
trigger = field::Empty,
|
|
script_hash = field::Empty,
|
|
otel.name = field::Empty,
|
|
otel.status_code = "ERROR",
|
|
otel.status_message = field::Empty,
|
|
success = %success,
|
|
error.message = field::Empty,
|
|
error.name = field::Empty,
|
|
labels = field::Empty,
|
|
)
|
|
};
|
|
let rj = if let Some(root_job) = jc.job.flow_innermost_root_job {
|
|
root_job
|
|
} else {
|
|
jc.job.id
|
|
};
|
|
|
|
if let Some(labels) = metadata.wm_labels.as_ref() {
|
|
if !labels.is_empty() {
|
|
span.record("labels", labels.join(","));
|
|
}
|
|
}
|
|
// The secondary `job_postprocessing` span stays on the UUID-derived context
|
|
// (MiniCompletedJob carries no args, so the inbound traceparent isn't
|
|
// available here); the primary job span is relocated in `create_span_with_name`.
|
|
windmill_common::otel_oss::set_span_parent(&span, &rj);
|
|
|
|
if let Some(lg) = jc.job.script_lang.as_ref() {
|
|
span.record("language", lg.as_str());
|
|
}
|
|
if let Some(step_id) = jc.job.flow_step_id.as_ref() {
|
|
span.record(
|
|
"otel.name",
|
|
format!("job_postprocessing {}", step_id).as_str(),
|
|
);
|
|
span.record("flow_step_id", step_id.as_str());
|
|
} else {
|
|
span.record("otel.name", "job postprocessing");
|
|
}
|
|
if let Some(parent_job) = jc.job.parent_job.as_ref() {
|
|
span.record("parent_job", parent_job.to_string().as_str());
|
|
}
|
|
if let Some(script_path) = jc.job.runnable_path.as_ref() {
|
|
span.record("script_path", script_path.as_str());
|
|
}
|
|
if let Some(root_job) = jc.job.flow_innermost_root_job.as_ref() {
|
|
span.record("root_job", root_job.to_string().as_str());
|
|
}
|
|
if let Some(trigger_kind) = jc.job.trigger_kind.as_ref() {
|
|
span.record("trigger_kind", trigger_kind.to_string().as_str());
|
|
}
|
|
if let Some(trigger) = jc.job.trigger.as_ref() {
|
|
span.record("trigger", trigger.as_str());
|
|
}
|
|
if let Some(script_hash) = jc.job.runnable_id.as_ref() {
|
|
span.record("script_hash", script_hash.to_string().as_str());
|
|
}
|
|
if !success {
|
|
if let Some(result_error) = extract_error_message(jc.result.get()) {
|
|
span.record("error.message", result_error.message.as_str());
|
|
span.record("error.name", result_error.name.as_str());
|
|
span.record(
|
|
"otel.status_message",
|
|
crate::worker::truncate_description(&result_error.message).as_str(),
|
|
);
|
|
} else {
|
|
span.record("otel.status_message", "Job failed");
|
|
}
|
|
}
|
|
|
|
// Extract stats info before moving jc
|
|
let duration_ms = jc.duration.clone();
|
|
let script_lang = jc.job.script_lang.clone();
|
|
let workspace_id = jc.job.workspace_id.clone();
|
|
|
|
let root_job = handle_receive_completed_job(
|
|
jc,
|
|
&base_internal_url,
|
|
&db,
|
|
worker_dir,
|
|
same_worker_tx,
|
|
&worker_name,
|
|
job_completed_sender.clone(),
|
|
killpill_rx,
|
|
#[cfg(feature = "benchmark")]
|
|
bench,
|
|
)
|
|
.instrument(span)
|
|
.warn_after_seconds(10)
|
|
.await;
|
|
|
|
if let Some(root_job) = root_job {
|
|
add_root_flow_job_to_otlp(&root_job, success);
|
|
|
|
#[cfg(feature = "benchmark")]
|
|
if bench_infos.count_top_level(root_job.id) {
|
|
bench_infos
|
|
.shared_iters
|
|
.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
|
|
}
|
|
}
|
|
|
|
// Accumulate job stats if duration is available
|
|
if let Some(duration_ms) = duration_ms {
|
|
accumulate_job_stats(
|
|
stats_map,
|
|
&*WORKER_GROUP,
|
|
script_lang,
|
|
&workspace_id,
|
|
duration_ms,
|
|
)
|
|
.await;
|
|
}
|
|
|
|
success
|
|
}
|
|
|
|
enum JobCompletedRx {
|
|
JobCompleted(SendResult),
|
|
Killpill,
|
|
WakeUp,
|
|
}
|
|
|
|
pub fn start_background_processor(
|
|
job_completed_rx: JobCompletedReceiver,
|
|
job_completed_sender: JobCompletedSender,
|
|
same_worker_queue_size: Arc<AtomicU16>,
|
|
job_completed_processor_is_done: Arc<AtomicBool>,
|
|
wake_up_notify: Arc<Notify>,
|
|
last_processing_duration: Arc<AtomicU16>,
|
|
base_internal_url: String,
|
|
db: DB,
|
|
worker_dir: String,
|
|
same_worker_tx: SameWorkerSender,
|
|
worker_name: String,
|
|
killpill_tx: KillpillSender,
|
|
is_dedicated_worker: bool,
|
|
// True when this processor runs inside the agent-worker API server, relaying
|
|
// completions on behalf of many remote agent workers. Such a processor must
|
|
// never kill itself: dropping its receiver would disconnect the shared
|
|
// job-completed channel and make every future /send_result fail until the
|
|
// whole server is restarted.
|
|
is_agent_server: bool,
|
|
stats_map: JobStatsMap,
|
|
) -> JoinHandle<()> {
|
|
tokio::spawn(async move {
|
|
let mut has_been_killed = false;
|
|
|
|
let JobCompletedReceiver { bounded_rx, mut killpill_rx, unbounded_rx } = job_completed_rx;
|
|
|
|
#[cfg(feature = "benchmark")]
|
|
let mut infos = BenchmarkInfo::new(windmill_common::bench::shared_bench_iters());
|
|
|
|
// Start periodic stats flush task
|
|
let db_clone = db.clone();
|
|
let stats_map_clone = stats_map.clone();
|
|
let mut killpill_rx_clone = killpill_rx.resubscribe();
|
|
let flush_handle = tokio::spawn(async move {
|
|
let mut interval = tokio::time::interval(tokio::time::Duration::from_secs(900)); // Flush every 15 min
|
|
|
|
loop {
|
|
tokio::select! {
|
|
_ = interval.tick() => {
|
|
if let Err(e) = flush_stats_to_db(&db_clone, &stats_map_clone).await {
|
|
tracing::error!("Failed to flush worker group job stats: {}", e);
|
|
}
|
|
}
|
|
_ = killpill_rx_clone.recv() => {
|
|
tracing::info!("bg processor received killpill signal, flushing remaining stats");
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
});
|
|
|
|
//if we have been killed, we want to drain the queue of jobs
|
|
while let Some(sr) = {
|
|
if has_been_killed {
|
|
tracing::info!("bg processor is killed, draining. same_worker_queue_size: {}, unbounded_rx: {}, bounded_rx: {}", same_worker_queue_size.load(Ordering::SeqCst), unbounded_rx.len(), bounded_rx.len())
|
|
}
|
|
if has_been_killed && same_worker_queue_size.load(Ordering::SeqCst) == 0 {
|
|
unbounded_rx
|
|
.try_recv()
|
|
.ok()
|
|
.map(JobCompletedRx::JobCompleted)
|
|
.or_else(|| bounded_rx.try_recv().ok().map(JobCompletedRx::JobCompleted))
|
|
} else {
|
|
tokio::select! {
|
|
biased;
|
|
result = unbounded_rx.recv_async() => {
|
|
result.ok().map(JobCompletedRx::JobCompleted)
|
|
}
|
|
result = bounded_rx.recv_async() => {
|
|
result.ok().map(JobCompletedRx::JobCompleted)
|
|
},
|
|
_ = wake_up_notify.notified() => {
|
|
tracing::info!("bg processor received wake up signal, checking if same worker queue is empty");
|
|
Some(JobCompletedRx::WakeUp)
|
|
},
|
|
_ = killpill_rx.recv() => {
|
|
tracing::info!("bg processor received killpill signal, queuing killpill job");
|
|
Some(JobCompletedRx::Killpill)
|
|
}
|
|
}
|
|
}
|
|
} {
|
|
#[cfg(feature = "benchmark")]
|
|
let mut bench = BenchmarkIter::new();
|
|
|
|
match sr {
|
|
JobCompletedRx::JobCompleted(SendResult {
|
|
result: SendResultPayload::JobCompleted(jc),
|
|
time,
|
|
}) => {
|
|
let is_init_script = jc.job.tag.as_str() == INIT_SCRIPT_TAG;
|
|
let is_dependency_job = matches!(
|
|
jc.job.kind,
|
|
JobKind::Dependencies | JobKind::FlowDependencies
|
|
);
|
|
let jc_id = jc.job.id;
|
|
#[cfg(feature = "benchmark")]
|
|
let bench_job_id = jc.job.id;
|
|
#[cfg(feature = "benchmark")]
|
|
let is_top_level_job = jc.job.parent_job.is_none();
|
|
|
|
// process_jc returns the post-override success value so a
|
|
// job that flipped to failure via `wm_failure` still
|
|
// triggers the init-script killpill.
|
|
let final_success = process_jc(
|
|
jc,
|
|
&worker_name,
|
|
&base_internal_url,
|
|
&db,
|
|
&worker_dir,
|
|
Some(&same_worker_tx),
|
|
&job_completed_sender,
|
|
&stats_map,
|
|
&killpill_rx,
|
|
#[cfg(feature = "benchmark")]
|
|
&mut bench,
|
|
#[cfg(feature = "benchmark")]
|
|
&mut infos,
|
|
)
|
|
.warn_after_seconds(10)
|
|
.await;
|
|
|
|
if is_init_script && !final_success {
|
|
if is_agent_server {
|
|
// The failed init script belongs to a remote agent
|
|
// worker, not to this server. That worker handles its
|
|
// own restart; killing the server relay here would
|
|
// strand every other agent worker's completions.
|
|
tracing::error!(
|
|
job_id = %jc_id,
|
|
"agent worker init script errored; failure recorded, keeping server bg processor alive"
|
|
);
|
|
} else {
|
|
tracing::error!("init script errored, exiting");
|
|
killpill_tx.send();
|
|
break;
|
|
}
|
|
}
|
|
if is_dependency_job && is_dedicated_worker {
|
|
tracing::error!("Dedicated worker executed a dependency job, a new script has been deployed. Exiting expecting to be restarted.");
|
|
sqlx::query!(
|
|
"UPDATE config SET config = config WHERE name = $1",
|
|
format!("worker__{}", *WORKER_GROUP)
|
|
)
|
|
.execute(&db)
|
|
.await
|
|
.expect("update config to trigger restart of all dedicated workers at that config");
|
|
killpill_tx.send();
|
|
}
|
|
add_time!(bench, "job completed processed");
|
|
|
|
#[cfg(feature = "benchmark")]
|
|
{
|
|
if infos.add_iter(bench, bench_job_id, is_top_level_job) {
|
|
infos.shared_iters.fetch_add(1, Ordering::Relaxed);
|
|
}
|
|
}
|
|
last_processing_duration
|
|
.store(time.elapsed().as_secs() as u16, Ordering::SeqCst);
|
|
}
|
|
JobCompletedRx::JobCompleted(SendResult {
|
|
result:
|
|
SendResultPayload::UpdateFlow(UpdateFlow {
|
|
flow,
|
|
w_id,
|
|
success,
|
|
result,
|
|
worker_dir,
|
|
stop_early_override,
|
|
token,
|
|
}),
|
|
time,
|
|
}) => {
|
|
// let r;
|
|
tracing::info!(parent_flow = %flow, "updating flow status after job completion");
|
|
if let Err(e) = update_flow_status_after_job_completion(
|
|
&db,
|
|
&AuthedClient::new(
|
|
base_internal_url.to_string(),
|
|
w_id.clone(),
|
|
token.clone(),
|
|
None,
|
|
),
|
|
flow,
|
|
&Uuid::nil(),
|
|
&w_id,
|
|
success,
|
|
None,
|
|
Arc::new(result),
|
|
None,
|
|
true,
|
|
&same_worker_tx,
|
|
&worker_dir,
|
|
stop_early_override,
|
|
&worker_name,
|
|
job_completed_sender.clone(),
|
|
None,
|
|
&killpill_rx,
|
|
#[cfg(feature = "benchmark")]
|
|
&mut bench,
|
|
)
|
|
.await
|
|
{
|
|
tracing::error!("Error updating flow status after job completion for {flow} on {worker_name}: {e:#}");
|
|
}
|
|
#[cfg(feature = "benchmark")]
|
|
{
|
|
if infos.add_iter(bench, flow, true) {
|
|
infos.shared_iters.fetch_add(1, Ordering::Relaxed);
|
|
}
|
|
}
|
|
last_processing_duration
|
|
.store(time.elapsed().as_secs() as u16, Ordering::SeqCst);
|
|
}
|
|
JobCompletedRx::Killpill => {
|
|
tracing::info!("killpill job received, processing only same worker jobs");
|
|
has_been_killed = true;
|
|
}
|
|
JobCompletedRx::WakeUp => {}
|
|
}
|
|
}
|
|
|
|
// Flush any remaining stats before shutting down
|
|
tracing::info!("flushing remaining stats before shutting down");
|
|
let flush_result =
|
|
tokio::time::timeout(std::time::Duration::from_secs(10), flush_handle).await;
|
|
match flush_result {
|
|
Ok(Ok(())) => tracing::info!("Stats flushed successfully"),
|
|
Ok(Err(join_err)) => tracing::error!("Stats flush task failed: {}", join_err),
|
|
Err(_) => tracing::error!("Stats flush timed out after 10 seconds"),
|
|
}
|
|
|
|
job_completed_processor_is_done.store(true, Ordering::SeqCst);
|
|
|
|
tracing::info!("finished processing all completed jobs");
|
|
|
|
#[cfg(feature = "benchmark")]
|
|
{
|
|
infos
|
|
.write_to_file("profiling_result_processor.json")
|
|
.expect("write to file profiling");
|
|
}
|
|
})
|
|
}
|
|
|
|
async fn send_job_completed(job_completed_tx: JobCompletedSender, jc: JobCompleted) {
|
|
if let Err(e) = job_completed_tx
|
|
.send_job(jc, true)
|
|
.with_context(windmill_common::otel_oss::otel_ctx())
|
|
.await
|
|
{
|
|
tracing::error!("send job completed failed, triggering worker shutdown: {e:#}");
|
|
job_completed_tx.send_worker_killpill();
|
|
}
|
|
}
|
|
|
|
pub async fn process_result(
|
|
job: MiniCompletedJob,
|
|
result: error::Result<Arc<Box<RawValue>>>,
|
|
job_dir: &str,
|
|
job_completed_tx: JobCompletedSender,
|
|
mem_peak: i32,
|
|
canceled_by: Option<CanceledBy>,
|
|
cached_res_path: Option<String>,
|
|
token: &str,
|
|
result_columns: Option<Vec<String>>,
|
|
preprocessed_args: Option<HashMap<String, Box<RawValue>>>,
|
|
conn: &Connection,
|
|
duration: Option<i64>,
|
|
has_stream: bool,
|
|
flow_runners: Option<Arc<FlowRunners>>,
|
|
) -> error::Result<crate::worker::JobOutcome> {
|
|
match result {
|
|
Ok(result) => {
|
|
send_job_completed(
|
|
job_completed_tx,
|
|
JobCompleted {
|
|
job,
|
|
preprocessed_args,
|
|
result,
|
|
result_columns,
|
|
mem_peak,
|
|
canceled_by,
|
|
success: true,
|
|
cached_res_path,
|
|
token: token.to_string(),
|
|
duration,
|
|
has_stream: Some(has_stream),
|
|
from_cache: None,
|
|
flow_runners,
|
|
done_tx: None,
|
|
},
|
|
)
|
|
.with_context(windmill_common::otel_oss::otel_ctx())
|
|
.await;
|
|
Ok(crate::worker::JobOutcome::Completed)
|
|
}
|
|
Err(e) => {
|
|
let error_value = match e {
|
|
Error::ExitStatus(program, i) => {
|
|
let res = read_result(job_dir, None).await.ok();
|
|
|
|
if res.as_ref().is_some_and(|x| !x.get().is_empty()) {
|
|
res.unwrap()
|
|
} else {
|
|
match conn {
|
|
Connection::Sql(db) => {
|
|
let last_10_log_lines = sqlx::query_scalar!(
|
|
"SELECT right(logs, 600) FROM job_logs WHERE job_id = $1 AND workspace_id = $2 ORDER BY created_at DESC LIMIT 1",
|
|
&job.id,
|
|
&job.workspace_id
|
|
).fetch_one(db).await.ok().flatten().unwrap_or("".to_string());
|
|
|
|
let log_lines = last_10_log_lines
|
|
.split("CODE EXECUTION ---")
|
|
.last()
|
|
.unwrap_or(&last_10_log_lines);
|
|
|
|
extract_error_value(
|
|
&program,
|
|
log_lines,
|
|
i,
|
|
job.flow_step_id.clone(),
|
|
)
|
|
}
|
|
Connection::Http(_) => {
|
|
to_raw_value(&"See logs for more details".to_string())
|
|
}
|
|
}
|
|
}
|
|
}
|
|
Error::ExecutionRawError(e) => to_raw_value(&e),
|
|
err @ _ => to_raw_value(&SerializedError {
|
|
message: format!("execution error:\n{err:#}",),
|
|
name: "ExecutionErr".to_string(),
|
|
step_id: job.flow_step_id.clone(),
|
|
exit_code: None,
|
|
}),
|
|
};
|
|
|
|
// Use the structured error message that was just extracted (the
|
|
// user-facing script error) rather than the generic Error string.
|
|
// Pull `.message` out of the JSON object if present, otherwise
|
|
// accept a bare string (e.g. agent-worker "See logs for more
|
|
// details"), and only fall back to "Job failed" when the value
|
|
// carries no readable description.
|
|
let description = serde_json::from_str::<serde_json::Value>(error_value.get())
|
|
.ok()
|
|
.and_then(|value| {
|
|
value
|
|
.get("message")
|
|
.and_then(|m| m.as_str())
|
|
.or_else(|| value.as_str())
|
|
.map(|m| crate::worker::truncate_description(m))
|
|
})
|
|
.unwrap_or_else(|| "Job failed".to_string());
|
|
|
|
send_job_completed(
|
|
job_completed_tx,
|
|
JobCompleted {
|
|
job,
|
|
result: Arc::new(to_raw_value(&error_value)),
|
|
result_columns: None,
|
|
preprocessed_args: None,
|
|
mem_peak,
|
|
canceled_by,
|
|
success: false,
|
|
cached_res_path,
|
|
token: token.to_string(),
|
|
duration,
|
|
has_stream: Some(has_stream),
|
|
from_cache: None,
|
|
flow_runners,
|
|
done_tx: None,
|
|
},
|
|
)
|
|
.with_context(windmill_common::otel_oss::otel_ctx())
|
|
.await;
|
|
Ok(crate::worker::JobOutcome::Failed { description })
|
|
}
|
|
}
|
|
}
|
|
|
|
pub async fn handle_receive_completed_job(
|
|
jc: JobCompleted,
|
|
base_internal_url: &str,
|
|
db: &DB,
|
|
worker_dir: &str,
|
|
same_worker_tx: Option<&SameWorkerSender>,
|
|
worker_name: &str,
|
|
job_completed_tx: JobCompletedSender,
|
|
killpill_rx: &tokio::sync::broadcast::Receiver<()>,
|
|
#[cfg(feature = "benchmark")] bench: &mut BenchmarkIter,
|
|
) -> Option<Arc<MiniPulledJob>> {
|
|
let token = jc.token.clone();
|
|
let workspace = jc.job.workspace_id.clone();
|
|
let client = AuthedClient::new(base_internal_url.to_string(), workspace, token, None);
|
|
let job = jc.job.clone();
|
|
let mem_peak = jc.mem_peak.clone();
|
|
let canceled_by = jc.canceled_by.clone();
|
|
|
|
let processed_completed_job = process_completed_job(
|
|
jc,
|
|
&client,
|
|
db,
|
|
&worker_dir,
|
|
same_worker_tx.clone(),
|
|
worker_name,
|
|
job_completed_tx.clone(),
|
|
killpill_rx,
|
|
#[cfg(feature = "benchmark")]
|
|
bench,
|
|
)
|
|
.warn_after_seconds(10)
|
|
.await;
|
|
|
|
match processed_completed_job {
|
|
// The job was already completed by another worker (e.g. this worker was
|
|
// declared a zombie and the job restarted+finished elsewhere, then this
|
|
// worker caught up). The job genuinely succeeded; routing this through
|
|
// `handle_job_error` would propagate a spurious "AlreadyCompleted"
|
|
// failure up the parent flow. Drop it instead, mirroring the
|
|
// `JobOutcome::AlreadyCompleted` guard on the execution path.
|
|
Err(err @ Error::AlreadyCompleted(_)) => {
|
|
tracing::info!(
|
|
job_id = %job.id,
|
|
"job already completed by another worker, skipping result processing: {err:#}"
|
|
);
|
|
None
|
|
}
|
|
Err(err) => {
|
|
handle_job_error(
|
|
db,
|
|
&client,
|
|
&job,
|
|
mem_peak,
|
|
canceled_by,
|
|
err,
|
|
false,
|
|
same_worker_tx.clone(),
|
|
&worker_dir,
|
|
worker_name,
|
|
job_completed_tx,
|
|
killpill_rx,
|
|
#[cfg(feature = "benchmark")]
|
|
bench,
|
|
)
|
|
.await;
|
|
None
|
|
}
|
|
Ok(r) => r,
|
|
}
|
|
}
|
|
|
|
/// A git-sync check run threaded through a pull job: the PR diff preview (phase 4)
|
|
/// or the live deploy status (phase 6). Both markers carry the same shape.
|
|
#[cfg(all(feature = "enterprise", feature = "private"))]
|
|
#[derive(serde::Deserialize)]
|
|
struct GitSyncCheck {
|
|
check_run_id: i64,
|
|
repo_url: String,
|
|
#[serde(default)]
|
|
pr_number: Option<i64>,
|
|
#[serde(default)]
|
|
head_sha: Option<String>,
|
|
/// Whether the PR itself modifies wmill.yaml (None = undetermined); picks
|
|
/// the wording for a settings difference in the diff summary.
|
|
#[serde(default)]
|
|
wmill_yaml_changed: Option<bool>,
|
|
}
|
|
|
|
/// Parsed diff summary from a (dry-run or real) pull result. `None` when the
|
|
/// result can't be parsed into the expected shape.
|
|
#[cfg(all(feature = "enterprise", feature = "private"))]
|
|
fn parse_git_sync_changes(result_raw: &str) -> Option<(Vec<(String, String)>, bool)> {
|
|
use serde::Deserialize;
|
|
#[derive(Deserialize)]
|
|
struct Change {
|
|
#[serde(rename = "type")]
|
|
change_type: String,
|
|
path: String,
|
|
}
|
|
#[derive(Deserialize)]
|
|
struct SettingsDiff {
|
|
#[serde(rename = "hasChanges", default)]
|
|
has_changes: bool,
|
|
}
|
|
#[derive(Deserialize)]
|
|
struct SyncResponse {
|
|
changes: Option<Vec<Change>>,
|
|
#[serde(rename = "settingsDiffResult")]
|
|
settings_diff_result: Option<SettingsDiff>,
|
|
}
|
|
let resp = serde_json::from_str::<SyncResponse>(result_raw).ok()?;
|
|
// A result carrying neither field isn't a recognizable diff; return None so the
|
|
// caller falls back to the unsummarized path instead of a false "in sync".
|
|
if resp.changes.is_none() && resp.settings_diff_result.is_none() {
|
|
return None;
|
|
}
|
|
let settings_changed = resp
|
|
.settings_diff_result
|
|
.map(|s| s.has_changes)
|
|
.unwrap_or(false);
|
|
Some((
|
|
resp.changes
|
|
.unwrap_or_default()
|
|
.into_iter()
|
|
.map(|c| (c.change_type, c.path))
|
|
.collect(),
|
|
settings_changed,
|
|
))
|
|
}
|
|
|
|
#[cfg(all(feature = "enterprise", feature = "private"))]
|
|
fn format_change_list(changes: &[(String, String)]) -> Vec<String> {
|
|
let mut lines = Vec::new();
|
|
for (change_type, path) in changes.iter().take(100) {
|
|
lines.push(format!("- `{}` {}", change_type, path));
|
|
}
|
|
if changes.len() > 100 {
|
|
lines.push(format!("- ... and {} more", changes.len() - 100));
|
|
}
|
|
lines
|
|
}
|
|
|
|
#[cfg(all(test, feature = "enterprise", feature = "private"))]
|
|
mod git_sync_check_tests {
|
|
use super::{format_change_list, parse_git_sync_changes};
|
|
|
|
#[test]
|
|
fn parse_empty_changes_is_in_sync() {
|
|
// Present-but-empty diff → a real "in sync" result, not None.
|
|
let (changes, settings) = parse_git_sync_changes(r#"{"changes":[]}"#).unwrap();
|
|
assert!(changes.is_empty());
|
|
assert!(!settings);
|
|
}
|
|
|
|
#[test]
|
|
fn parse_missing_fields_is_none() {
|
|
// Neither field present → unrecognizable, falls back to the caller's path.
|
|
assert!(parse_git_sync_changes("{}").is_none());
|
|
}
|
|
|
|
#[test]
|
|
fn parse_unparseable_is_none() {
|
|
assert!(parse_git_sync_changes("not json").is_none());
|
|
}
|
|
|
|
#[test]
|
|
fn parse_changes_and_settings() {
|
|
let (changes, settings) = parse_git_sync_changes(
|
|
r#"{"changes":[{"type":"edited","path":"f/a"}],"settingsDiffResult":{"hasChanges":true}}"#,
|
|
)
|
|
.unwrap();
|
|
assert_eq!(changes, vec![("edited".to_string(), "f/a".to_string())]);
|
|
assert!(settings);
|
|
}
|
|
|
|
#[test]
|
|
fn format_truncates_over_100() {
|
|
let changes: Vec<(String, String)> = (0..150)
|
|
.map(|i| ("edited".to_string(), format!("f/{i}")))
|
|
.collect();
|
|
let lines = format_change_list(&changes);
|
|
assert_eq!(lines.len(), 101);
|
|
assert_eq!(lines.last().unwrap(), "- ... and 50 more");
|
|
}
|
|
}
|
|
|
|
/// When an auto-pull job (carrying `__git_sync_auto_pull`) fails, roll the
|
|
/// optimistic `last_synced_sha` advance back to the pre-pull value so the commit
|
|
/// is retried instead of being silently treated as synced, and record the failure.
|
|
#[cfg(all(feature = "enterprise", feature = "private"))]
|
|
async fn maybe_reconcile_git_sync_auto_pull(
|
|
db: &DB,
|
|
job_id: &uuid::Uuid,
|
|
workspace_id: &str,
|
|
success: bool,
|
|
) {
|
|
if success {
|
|
return; // the optimistic synced state is already correct
|
|
}
|
|
let marker: Option<serde_json::Value> = match sqlx::query_scalar!(
|
|
"SELECT args->'__git_sync_auto_pull' FROM v2_job WHERE id = $1",
|
|
job_id
|
|
)
|
|
.fetch_optional(db)
|
|
.await
|
|
{
|
|
Ok(v) => v.flatten(),
|
|
Err(e) => {
|
|
tracing::error!("git auto-pull: failed to read job args: {e:#}");
|
|
return;
|
|
}
|
|
};
|
|
let Some(marker) = marker else {
|
|
return;
|
|
};
|
|
#[derive(serde::Deserialize)]
|
|
struct AutoPullMarker {
|
|
repo_resource_path: String,
|
|
#[serde(default)]
|
|
prev_synced: std::collections::HashMap<String, String>,
|
|
}
|
|
let Ok(m) = serde_json::from_value::<AutoPullMarker>(marker) else {
|
|
return;
|
|
};
|
|
windmill_git_sync::record_auto_pull_failure(
|
|
db,
|
|
workspace_id,
|
|
&m.repo_resource_path,
|
|
&m.prev_synced,
|
|
"auto-pull job failed".to_string(),
|
|
)
|
|
.await;
|
|
}
|
|
|
|
/// Branch a git-sync push job deployed to, mirroring the hub script's
|
|
/// derivation: a dev workspace deploys to its environment-label branch
|
|
/// (`dev`/`staging`), other fork workspaces to `wm-fork/<base>/<id-suffix>`,
|
|
/// else the promotion `wm_deploy/**` formula (per-folder or per-item form).
|
|
/// A dev workspace in promotion mode is the exception: it takes the promotion
|
|
/// `wm_deploy/**` formula (per-item PRs into the parent) instead of its label
|
|
/// branch. `None` when the deploy stays on the base branch (workspace-wide
|
|
/// mode) and has no PR to open.
|
|
#[cfg(all(feature = "enterprise", feature = "private"))]
|
|
fn git_sync_deploy_pr_head_branch(
|
|
workspace_id: &str,
|
|
parent_workspace_id: Option<&str>,
|
|
dev_workspace_label: Option<&str>,
|
|
base: &str,
|
|
use_individual_branch: bool,
|
|
group_by_folder: bool,
|
|
item_path: &str,
|
|
item_parent_path: &str,
|
|
path_type: &str,
|
|
) -> Option<String> {
|
|
let is_dev = dev_workspace_label.is_some();
|
|
// A dev workspace with promotion on falls through to the wm_deploy/**
|
|
// formula below; the label/fork branches only apply when promotion is off.
|
|
if !(is_dev && use_individual_branch) {
|
|
if is_dev {
|
|
return Some(windmill_common::workspaces::dev_workspace_branch(
|
|
dev_workspace_label,
|
|
));
|
|
}
|
|
let is_fork = parent_workspace_id.is_some()
|
|
|| workspace_id.starts_with(windmill_common::workspaces::WM_FORK_PREFIX);
|
|
if is_fork {
|
|
let suffix = workspace_id
|
|
.strip_prefix(windmill_common::workspaces::WM_FORK_PREFIX)
|
|
.unwrap_or(workspace_id);
|
|
return Some(format!("wm-fork/{base}/{suffix}"));
|
|
}
|
|
}
|
|
if !use_individual_branch {
|
|
return None;
|
|
}
|
|
// Mirrors the CLI's computeGitSyncDeployBranch: user/group objects are
|
|
// pushed to the base branch and never get their own wm_deploy branch.
|
|
if path_type == "user" || path_type == "group" {
|
|
return None;
|
|
}
|
|
let git_ref = if !item_path.is_empty() {
|
|
item_path
|
|
} else {
|
|
item_parent_path
|
|
};
|
|
if git_ref.is_empty() {
|
|
return None;
|
|
}
|
|
Some(if group_by_folder {
|
|
format!(
|
|
"wm_deploy/{workspace_id}/{}",
|
|
git_ref.split('/').take(2).collect::<Vec<_>>().join("__")
|
|
)
|
|
} else {
|
|
format!(
|
|
"wm_deploy/{workspace_id}/{}/{}",
|
|
path_type,
|
|
git_ref.replace('/', "__")
|
|
)
|
|
})
|
|
}
|
|
|
|
/// Whether the push job's result says a commit was actually pushed. `None`
|
|
/// when the result doesn't carry the flag (hub script versions predating it).
|
|
#[cfg(all(feature = "enterprise", feature = "private"))]
|
|
fn git_sync_push_result_pushed(result: &str) -> Option<bool> {
|
|
serde_json::from_str::<serde_json::Value>(result)
|
|
.ok()?
|
|
.get("pushed")?
|
|
.as_bool()
|
|
}
|
|
|
|
/// When a git-sync push job carrying `__git_sync_open_pr` succeeds, open (or
|
|
/// reopen) the PR for the branch it pushed: `wm-fork/<base>/<id>` for a fork
|
|
/// deploy, `wm_deploy/**` for a promotion deploy. Runs outbound with the
|
|
/// installation token, so it works regardless of webhook reachability.
|
|
/// Best-effort: failures are logged, never propagated.
|
|
#[cfg(all(feature = "enterprise", feature = "private"))]
|
|
async fn maybe_open_git_sync_deploy_pr(
|
|
db: &DB,
|
|
job_id: &uuid::Uuid,
|
|
workspace_id: &str,
|
|
result: &str,
|
|
) {
|
|
// A no-op push (workspace already matches the repo — e.g. the deploy was
|
|
// itself caused by an auto-pull) must not ensure a PR: it would recreate
|
|
// PRs the user closed and spam creation attempts with no diff.
|
|
if git_sync_push_result_pushed(result) == Some(false) {
|
|
return;
|
|
}
|
|
let row = match sqlx::query!(
|
|
r#"SELECT
|
|
args->'__git_sync_open_pr' as "marker",
|
|
args->>'repo_url_resource_path' as "repo_path",
|
|
args->>'parent_workspace_id' as "parent_workspace_id",
|
|
args->>'dev_workspace_label' as "dev_workspace_label",
|
|
args->>'parent_dev_workspace_label' as "parent_dev_workspace_label",
|
|
COALESCE((args->'use_individual_branch')::bool, false) as "use_individual_branch!",
|
|
COALESCE((args->'group_by_folder')::bool, false) as "group_by_folder!",
|
|
COALESCE(args->'items'->0->>'path', args->>'path', '') as "item_path!",
|
|
COALESCE(args->'items'->0->>'parent_path', args->>'parent_path', '') as "item_parent_path!",
|
|
COALESCE(args->'items'->0->>'path_type', args->>'path_type', '') as "path_type!",
|
|
COALESCE(args->'items'->0->>'commit_msg', args->>'commit_msg', '') as "commit_msg!"
|
|
FROM v2_job WHERE id = $1"#,
|
|
job_id
|
|
)
|
|
.fetch_optional(db)
|
|
.await
|
|
{
|
|
Ok(Some(r)) => r,
|
|
Ok(None) => return,
|
|
Err(e) => {
|
|
tracing::error!("git sync PR: failed to read job args: {e:#}");
|
|
return;
|
|
}
|
|
};
|
|
if row.marker.is_none() {
|
|
return;
|
|
}
|
|
// Runtime Enterprise gate, like the poller: the toggles may have been set
|
|
// while a license was active (or written directly), and this hook drives
|
|
// GitHub API calls with the installation token.
|
|
if !matches!(
|
|
windmill_common::ee_oss::get_license_plan().await,
|
|
windmill_common::ee_oss::LicensePlan::Enterprise
|
|
) {
|
|
tracing::warn!(
|
|
"git sync PR: skipping PR creation for {workspace_id}: requires an Enterprise license"
|
|
);
|
|
return;
|
|
}
|
|
let Some(repo_path) = row.repo_path else {
|
|
return;
|
|
};
|
|
|
|
// Base = the tracked branch (resource branch, else the repo default). Also
|
|
// acts as the app-backed gate: PR creation needs the installation token.
|
|
let base = match windmill_common::git_sync_ee::get_app_repo_head_for_autopull(
|
|
db,
|
|
workspace_id,
|
|
&repo_path,
|
|
)
|
|
.await
|
|
{
|
|
Ok(Some((branch, _))) => branch,
|
|
Ok(None) => {
|
|
tracing::warn!(
|
|
"git sync PR: repo {repo_path} in {workspace_id} has a PR-on-deploy toggle set but is not GitHub-App-backed; skipping (connect the repo through the GitHub App, or use the open-pr-on-commit workflow)"
|
|
);
|
|
return;
|
|
}
|
|
Err(e) => {
|
|
tracing::warn!("git sync PR: could not resolve base branch for {repo_path}: {e:#}");
|
|
return;
|
|
}
|
|
};
|
|
|
|
let Some(head) = git_sync_deploy_pr_head_branch(
|
|
workspace_id,
|
|
row.parent_workspace_id.as_deref(),
|
|
row.dev_workspace_label.as_deref(),
|
|
&base,
|
|
row.use_individual_branch,
|
|
row.group_by_folder,
|
|
&row.item_path,
|
|
&row.item_parent_path,
|
|
&row.path_type,
|
|
) else {
|
|
return;
|
|
};
|
|
|
|
let repo_url = match windmill_common::git_sync_ee::resolve_repo_url_interpolated(
|
|
db,
|
|
workspace_id,
|
|
&repo_path,
|
|
)
|
|
.await
|
|
{
|
|
Ok(url) => url,
|
|
Err(e) => {
|
|
tracing::warn!("git sync PR: could not resolve repo url for {repo_path}: {e:#}");
|
|
return;
|
|
}
|
|
};
|
|
// A fork of a dev workspace diverged from the dev's label branch, so its PR
|
|
// merges back there; everything else targets the tracked branch.
|
|
let pr_base = row.parent_dev_workspace_label.as_deref().unwrap_or(&base);
|
|
match windmill_common::git_sync_ee::ensure_pull_request(
|
|
db,
|
|
workspace_id,
|
|
&repo_url,
|
|
&head,
|
|
pr_base,
|
|
&row.commit_msg,
|
|
)
|
|
.await
|
|
{
|
|
Ok(()) => {
|
|
persist_git_sync_open_pr_error(db, workspace_id, &repo_path, None).await;
|
|
}
|
|
Err(e) => {
|
|
tracing::warn!(
|
|
"git sync PR: failed to open PR {head} -> {pr_base} for {repo_path}: {e:#}"
|
|
);
|
|
let msg: String = format!("{e:#}").chars().take(400).collect();
|
|
persist_git_sync_open_pr_error(db, workspace_id, &repo_path, Some(msg)).await;
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Best-effort: record (or clear) the last PR-creation failure on the repo's
|
|
/// settings so the toggle can explain a silent no-op in the UI (the usual
|
|
/// cause is a GitHub App installation that hasn't approved the pull-request
|
|
/// permission yet). Merges into a fresh read of the row, only writes on change.
|
|
#[cfg(all(feature = "enterprise", feature = "private"))]
|
|
async fn persist_git_sync_open_pr_error(
|
|
db: &DB,
|
|
workspace_id: &str,
|
|
repo_path: &str,
|
|
error: Option<String>,
|
|
) {
|
|
// Single targeted update (like the EE auto-pull status writer): a full
|
|
// read-modify-write of the column would race the poller's concurrent
|
|
// last_synced_sha/last_pull_status writes and silently clobber them.
|
|
let bare_path = repo_path.trim_start_matches("$res:");
|
|
let result: error::Result<()> = async {
|
|
sqlx::query!(
|
|
r#"
|
|
UPDATE workspace_settings
|
|
SET git_sync = jsonb_set(
|
|
git_sync,
|
|
'{repositories}',
|
|
(SELECT jsonb_agg(
|
|
CASE WHEN elem->>'git_repo_resource_path' IN ($2, '$res:' || $2)
|
|
THEN CASE WHEN $3::text IS NULL THEN elem - 'open_pr_error'
|
|
ELSE jsonb_set(elem, '{open_pr_error}', to_jsonb($3::text), true) END
|
|
ELSE elem END)
|
|
FROM jsonb_array_elements(git_sync->'repositories') AS elem)
|
|
)
|
|
WHERE workspace_id = $1
|
|
AND jsonb_typeof(git_sync->'repositories') = 'array'
|
|
"#,
|
|
workspace_id,
|
|
bare_path,
|
|
error.as_deref(),
|
|
)
|
|
.execute(db)
|
|
.await?;
|
|
Ok(())
|
|
}
|
|
.await;
|
|
if let Err(e) = result {
|
|
tracing::warn!("git sync PR: failed to persist open_pr_error for {repo_path}: {e:#}");
|
|
}
|
|
}
|
|
|
|
/// When a git-sync pull job carrying a check marker completes, post the outcome
|
|
/// to its GitHub check run: the PR diff preview (`__git_sync_pr_check`, phase 4)
|
|
/// or the live deploy status (`__git_sync_deploy_check`, phase 6).
|
|
/// A one-line description of the repo's sync filters, appended to an "in sync"
|
|
/// PR verdict: a PR that only touches files outside these paths deploys
|
|
/// nothing on merge, which otherwise looks like a wrong verdict.
|
|
#[cfg(all(feature = "enterprise", feature = "private"))]
|
|
fn format_git_sync_scope_note(include: &[String], exclude: &[String]) -> Option<String> {
|
|
if include.is_empty() {
|
|
return None;
|
|
}
|
|
let fmt = |paths: &[String]| {
|
|
paths
|
|
.iter()
|
|
.map(|p| format!("`{p}`"))
|
|
.collect::<Vec<_>>()
|
|
.join(", ")
|
|
};
|
|
let mut note = format!(
|
|
"\n\nOnly files matching this repository's sync filters deploy on merge: {}",
|
|
fmt(include)
|
|
);
|
|
if !exclude.is_empty() {
|
|
note.push_str(&format!(" (excluding {})", fmt(exclude)));
|
|
}
|
|
note.push('.');
|
|
Some(note)
|
|
}
|
|
|
|
#[cfg(all(feature = "enterprise", feature = "private"))]
|
|
async fn git_sync_repo_scope_note(
|
|
db: &DB,
|
|
workspace_id: &str,
|
|
repo_path: Option<&str>,
|
|
) -> Option<String> {
|
|
let repo_path = repo_path?;
|
|
let settings = sqlx::query_scalar!(
|
|
"SELECT git_sync FROM workspace_settings WHERE workspace_id = $1",
|
|
workspace_id
|
|
)
|
|
.fetch_optional(db)
|
|
.await
|
|
.ok()?
|
|
.flatten()?;
|
|
let settings: windmill_common::workspaces::WorkspaceGitSyncSettings =
|
|
serde_json::from_value(settings).ok()?;
|
|
// Job args carry the bare resource path; stored settings keep the $res: prefix.
|
|
let repo = settings.repositories.iter().find(|r| {
|
|
r.git_repo_resource_path.trim_start_matches("$res:")
|
|
== repo_path.trim_start_matches("$res:")
|
|
})?;
|
|
let s = repo.settings.as_ref()?;
|
|
format_git_sync_scope_note(
|
|
&s.include_path,
|
|
s.exclude_path.as_deref().unwrap_or_default(),
|
|
)
|
|
}
|
|
|
|
#[cfg(all(feature = "enterprise", feature = "private"))]
|
|
async fn maybe_post_git_sync_check(
|
|
db: &DB,
|
|
job_id: &uuid::Uuid,
|
|
workspace_id: &str,
|
|
success: bool,
|
|
result_raw: &str,
|
|
) {
|
|
// Only git-sync pull jobs carry one of these markers; everything else no-ops.
|
|
let row = match sqlx::query!(
|
|
r#"SELECT args->'__git_sync_pr_check' AS "pr", args->'__git_sync_deploy_check' AS "deploy",
|
|
args->>'repo_url_resource_path' AS "repo_path"
|
|
FROM v2_job WHERE id = $1"#,
|
|
job_id
|
|
)
|
|
.fetch_optional(db)
|
|
.await
|
|
{
|
|
Ok(r) => r,
|
|
Err(e) => {
|
|
tracing::error!("git sync-check: failed to read job args: {e:#}");
|
|
return;
|
|
}
|
|
};
|
|
let Some(row) = row else {
|
|
return;
|
|
};
|
|
// A PR dry-run and a deploy pull are mutually exclusive markers.
|
|
let (is_deploy, marker) = match (row.pr, row.deploy) {
|
|
(Some(pr), _) => (false, pr),
|
|
(None, Some(deploy)) => (true, deploy),
|
|
(None, None) => return,
|
|
};
|
|
let Ok(mut check) = serde_json::from_value::<GitSyncCheck>(marker) else {
|
|
return;
|
|
};
|
|
// Markers carry the literal resource URL (job args are persisted, so a
|
|
// `$var:`-resolved URL must not land there); interpolate before calling
|
|
// GitHub.
|
|
check.repo_url =
|
|
match windmill_common::variables::get_variable_or_self(check.repo_url, db, workspace_id)
|
|
.await
|
|
{
|
|
Ok(u) => u,
|
|
Err(e) => {
|
|
tracing::error!("git sync-check: cannot interpolate repo url: {e:#}");
|
|
return;
|
|
}
|
|
};
|
|
// "In sync" on a PR that visibly changes files reads as a bug when those
|
|
// files are outside the repo's sync filters — say what the scope is.
|
|
let scope_note = if !is_deploy && success {
|
|
git_sync_repo_scope_note(db, workspace_id, row.repo_path.as_deref()).await
|
|
} else {
|
|
None
|
|
};
|
|
|
|
let (conclusion, title, summary): (&str, String, String) = if is_deploy {
|
|
// Phase 6: real deploy pull -> "Deployed N changes" / "In sync" / failure.
|
|
if !success {
|
|
(
|
|
"failure",
|
|
format!("Deploy to {} failed", workspace_id),
|
|
"Deploying the latest commit failed. See the job in Windmill for details."
|
|
.to_string(),
|
|
)
|
|
} else {
|
|
match parse_git_sync_changes(result_raw) {
|
|
Some((changes, settings_changed)) if changes.is_empty() && !settings_changed => (
|
|
"success",
|
|
format!("In sync with {}", workspace_id),
|
|
format!(
|
|
"No changes to deploy to `{}` from this commit.",
|
|
workspace_id
|
|
),
|
|
),
|
|
Some((changes, settings_changed)) => {
|
|
let mut lines = vec![format!(
|
|
"Deployed {} change(s) to `{}`:\n",
|
|
changes.len(),
|
|
workspace_id
|
|
)];
|
|
lines.extend(format_change_list(&changes));
|
|
if settings_changed {
|
|
lines.push("\nWorkspace settings also changed.".to_string());
|
|
}
|
|
(
|
|
"success",
|
|
format!("Deployed {} change(s) to {}", changes.len(), workspace_id),
|
|
lines.join("\n"),
|
|
)
|
|
}
|
|
None => (
|
|
"success",
|
|
format!("Deployed to {}", workspace_id),
|
|
format!("Windmill deployed the latest commit to `{}`.", workspace_id),
|
|
),
|
|
}
|
|
}
|
|
} else {
|
|
// Phase 4: dry-run diff preview for a PR.
|
|
if !success {
|
|
(
|
|
"failure",
|
|
"Windmill diff failed".to_string(),
|
|
"The dry-run pull to compute the diff failed. See the job in Windmill for details."
|
|
.to_string(),
|
|
)
|
|
} else {
|
|
match parse_git_sync_changes(result_raw) {
|
|
Some((changes, settings_changed)) if changes.is_empty() && !settings_changed => (
|
|
"success",
|
|
"In sync".to_string(),
|
|
format!(
|
|
"Merging this PR would make no changes to the workspace.{}",
|
|
scope_note.as_deref().unwrap_or_default()
|
|
),
|
|
),
|
|
Some((changes, settings_changed)) => {
|
|
let mut lines = vec![format!(
|
|
"Merging this PR would apply {} change(s) to the workspace:\n",
|
|
changes.len()
|
|
)];
|
|
lines.extend(format_change_list(&changes));
|
|
if settings_changed {
|
|
lines.push(match check.wmill_yaml_changed {
|
|
Some(true) => "\nThis PR changes wmill.yaml: pulling also applies the updated workspace settings.".to_string(),
|
|
Some(false) => "\nIndependent of this PR, the workspace's git-sync settings differ from the repo's wmill.yaml and a pull updates them to match.".to_string(),
|
|
None => "\nA pull also updates the workspace's git-sync settings to match the repo's wmill.yaml.".to_string(),
|
|
});
|
|
}
|
|
(
|
|
"neutral",
|
|
format!("{} change(s) to deploy", changes.len()),
|
|
lines.join("\n"),
|
|
)
|
|
}
|
|
None => (
|
|
"neutral",
|
|
"Diff computed".to_string(),
|
|
"Windmill computed a diff but could not summarize it.".to_string(),
|
|
),
|
|
}
|
|
}
|
|
};
|
|
|
|
if let Err(e) = windmill_common::git_sync_ee::update_check_run(
|
|
db,
|
|
workspace_id,
|
|
&check.repo_url,
|
|
check.check_run_id,
|
|
conclusion,
|
|
&title,
|
|
&summary,
|
|
)
|
|
.await
|
|
{
|
|
tracing::error!("git sync-check: failed to update check run: {e:#}");
|
|
}
|
|
|
|
// Phase 4 also maintains ONE managed comment on the PR (Cloudflare
|
|
// deploy-preview style): upserted on every synchronize, so reviewers see the
|
|
// current diff without opening the Checks tab.
|
|
if !is_deploy {
|
|
if let Some(pr_number) = check.pr_number {
|
|
let marker = "<!-- windmill-diff -->";
|
|
let head = check
|
|
.head_sha
|
|
.as_deref()
|
|
.map(|s| &s[..s.len().min(7)])
|
|
.unwrap_or("latest");
|
|
let body = format!(
|
|
"{marker}\n### Windmill deploy preview\n\n| | |\n|---|---|\n| **Workspace** | `{workspace_id}` |\n| **Status** | {title} |\n| **Commit** | `{head}` |\n\n<details><summary>Details</summary>\n\n{summary}\n\n</details>"
|
|
);
|
|
if let Err(e) = windmill_common::git_sync_ee::upsert_pr_comment(
|
|
db,
|
|
workspace_id,
|
|
&check.repo_url,
|
|
pr_number,
|
|
marker,
|
|
&body,
|
|
)
|
|
.await
|
|
{
|
|
tracing::warn!("git sync-check: failed to upsert PR diff comment: {e:#}");
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
pub async fn process_completed_job(
|
|
JobCompleted {
|
|
job,
|
|
result,
|
|
mem_peak,
|
|
success,
|
|
cached_res_path,
|
|
canceled_by,
|
|
duration,
|
|
result_columns,
|
|
preprocessed_args,
|
|
from_cache,
|
|
flow_runners,
|
|
done_tx,
|
|
..
|
|
}: JobCompleted,
|
|
client: &AuthedClient,
|
|
db: &DB,
|
|
worker_dir: &str,
|
|
same_worker_tx: Option<&SameWorkerSender>,
|
|
worker_name: &str,
|
|
job_completed_tx: JobCompletedSender,
|
|
killpill_rx: &tokio::sync::broadcast::Receiver<()>,
|
|
#[cfg(feature = "benchmark")] bench: &mut BenchmarkIter,
|
|
) -> error::Result<Option<Arc<MiniPulledJob>>> {
|
|
if success {
|
|
// println!("bef completed job{:?}", SystemTime::now());
|
|
if let Some(cached_path) = cached_res_path {
|
|
save_in_cache(db, client, &job, cached_path, result.clone()).await;
|
|
}
|
|
|
|
let is_flow_step = job.is_flow_step();
|
|
let parent_job = job.parent_job.clone();
|
|
let job_id = job.id.clone();
|
|
let workspace_id = job.workspace_id.clone();
|
|
let started_at = job.started_at.clone();
|
|
|
|
if job.flow_step_id.as_deref() == Some("preprocessor") {
|
|
// Do this before inserting to `v2_job_completed` for backwards compatibility
|
|
// when we set `flow_status->_metadata->preprocessed_args` to true.
|
|
|
|
sqlx::query!(
|
|
r#"UPDATE v2_job SET
|
|
args = '{"reason":"PREPROCESSOR_ARGS_ARE_DISCARDED"}'::jsonb,
|
|
preprocessed = TRUE
|
|
WHERE id = $1 AND preprocessed = FALSE"#,
|
|
job.id
|
|
)
|
|
.execute(db)
|
|
.await
|
|
.map_err(|e| {
|
|
Error::InternalErr(format!(
|
|
"error while deleting args of preprocessing step: {e:#}"
|
|
))
|
|
})?;
|
|
} else if let Some(preprocessed_args) = preprocessed_args {
|
|
// Update script args to preprocessed args, but preserve a
|
|
// resolved pipeline `partition` (injected before the body ran
|
|
// by resolve_partition_for_job). Run identity is immutable —
|
|
// the preprocessor must not change or drop it, or the asset
|
|
// cascade would read no partition for this producer.
|
|
windmill_common::partition::merge_args_preserving_partition(
|
|
db,
|
|
job.id,
|
|
preprocessed_args,
|
|
)
|
|
.await?;
|
|
}
|
|
|
|
add_time!(bench, "pre add_completed_job");
|
|
|
|
let (_, duration, wac_job_ids) = add_completed_job(
|
|
db,
|
|
&job,
|
|
true,
|
|
false,
|
|
Json(&result),
|
|
result_columns,
|
|
mem_peak.to_owned(),
|
|
canceled_by.clone(),
|
|
false,
|
|
duration,
|
|
from_cache.unwrap_or(false),
|
|
)
|
|
.await?;
|
|
#[cfg(all(feature = "enterprise", feature = "private"))]
|
|
if job.kind == JobKind::DeploymentCallback {
|
|
maybe_post_git_sync_check(db, &job_id, &workspace_id, true, result.get()).await;
|
|
maybe_open_git_sync_deploy_pr(db, &job_id, &workspace_id, result.get()).await;
|
|
}
|
|
|
|
// Asset-trigger fan-out: best-effort, never propagates errors.
|
|
// Internal eligibility checks gate to top-level Script/Preview runs;
|
|
// see windmill_queue::asset_dispatch.
|
|
asset_dispatch::dispatch_asset_triggers(db, &job).await;
|
|
|
|
drop(job);
|
|
|
|
add_time!(bench, "add_completed_job END");
|
|
|
|
if is_flow_step {
|
|
if let Some(parent_job) = parent_job {
|
|
// tracing::info!(parent_flow = %parent_job, subflow = %job_id, "updating flow status (2)");
|
|
let r = update_flow_status_after_job_completion(
|
|
db,
|
|
client,
|
|
parent_job,
|
|
&job_id,
|
|
&workspace_id,
|
|
true,
|
|
canceled_by,
|
|
result,
|
|
started_at.map(|x| FlowJobDuration { started_at: x, duration_ms: duration }),
|
|
false,
|
|
&same_worker_tx.expect(SAME_WORKER_REQUIREMENTS).to_owned(),
|
|
&worker_dir,
|
|
None,
|
|
worker_name,
|
|
job_completed_tx,
|
|
flow_runners,
|
|
killpill_rx,
|
|
#[cfg(feature = "benchmark")]
|
|
bench,
|
|
)
|
|
.warn_after_seconds(10)
|
|
.await?;
|
|
add_time!(bench, "updated flow status END");
|
|
if let Some(done_tx) = done_tx {
|
|
done_tx
|
|
.send(())
|
|
.expect("done receiver should still be alive");
|
|
}
|
|
return Ok(r);
|
|
}
|
|
} else if let Some(parent_job) = parent_job {
|
|
// wac_job_ids is piggybacked from the duration write in
|
|
// add_completed_job — no extra query needed.
|
|
if let Some(job_ids) = wac_job_ids {
|
|
if let Ok(Some(_)) = handle_wac_child_completion(
|
|
db,
|
|
&job_id,
|
|
parent_job,
|
|
&workspace_id,
|
|
result,
|
|
true,
|
|
job_ids,
|
|
)
|
|
.await
|
|
{
|
|
if let Some(done_tx) = done_tx {
|
|
done_tx
|
|
.send(())
|
|
.expect("done receiver should still be alive");
|
|
}
|
|
return Ok(None);
|
|
}
|
|
}
|
|
}
|
|
} else {
|
|
// The result already carries our injected
|
|
// `error: { name: "ManualFailure", ... }` marker when process_jc
|
|
// retagged a successful run as a failure — store it as-is to preserve
|
|
// sibling fields like `windmill_status_code`. We check for the
|
|
// injected marker specifically (not just the presence of a
|
|
// `wm_failure` field) so a real runtime failure whose raw
|
|
// result happens to contain a `wm_failure` field still goes
|
|
// through the standard `WrappedError { error: ... }` wrap path.
|
|
let downstream_result: Arc<Box<RawValue>> = if is_pre_shaped_wm_failure_result(result.get())
|
|
{
|
|
windmill_queue::add_completed_job_pre_shaped_failure(
|
|
db,
|
|
&job,
|
|
mem_peak.to_owned(),
|
|
canceled_by.clone(),
|
|
Json(&*result),
|
|
worker_name,
|
|
false,
|
|
None,
|
|
)
|
|
.await?;
|
|
result.clone()
|
|
} else {
|
|
let wrapped = add_completed_job_error(
|
|
db,
|
|
&job,
|
|
mem_peak.to_owned(),
|
|
canceled_by.clone(),
|
|
serde_json::from_str(result.get()).unwrap_or_else(
|
|
|_| json!({ "message": format!("Non serializable error: {}", result.get()) }),
|
|
),
|
|
worker_name,
|
|
false,
|
|
None,
|
|
)
|
|
.await?;
|
|
Arc::new(serde_json::value::to_raw_value(&wrapped).unwrap())
|
|
};
|
|
#[cfg(all(feature = "enterprise", feature = "private"))]
|
|
if job.kind == JobKind::DeploymentCallback {
|
|
maybe_post_git_sync_check(db, &job.id, &job.workspace_id, false, result.get()).await;
|
|
maybe_reconcile_git_sync_auto_pull(db, &job.id, &job.workspace_id, false).await;
|
|
}
|
|
if job.is_flow_step() {
|
|
if let Some(parent_job) = job.parent_job {
|
|
tracing::error!(parent_flow = %parent_job, subflow = %job.id, "process completed job error, updating flow status");
|
|
let r = update_flow_status_after_job_completion(
|
|
db,
|
|
client,
|
|
parent_job,
|
|
&job.id,
|
|
&job.workspace_id,
|
|
false,
|
|
canceled_by,
|
|
downstream_result,
|
|
duration.and_then(|d| {
|
|
job.started_at.map(|started_at| FlowJobDuration {
|
|
started_at: started_at,
|
|
duration_ms: d,
|
|
})
|
|
}),
|
|
false,
|
|
&same_worker_tx.expect(SAME_WORKER_REQUIREMENTS).to_owned(),
|
|
&worker_dir,
|
|
None,
|
|
worker_name,
|
|
job_completed_tx,
|
|
flow_runners,
|
|
killpill_rx,
|
|
#[cfg(feature = "benchmark")]
|
|
bench,
|
|
)
|
|
.warn_after_seconds(10)
|
|
.await?;
|
|
if let Some(done_tx) = done_tx {
|
|
done_tx
|
|
.send(())
|
|
.expect("done receiver should still be alive");
|
|
}
|
|
return Ok(r);
|
|
}
|
|
} else if let Some(parent_job) = job.parent_job {
|
|
// WAC child failed — query job_ids from parent (errors are rare,
|
|
// so the extra read is acceptable here).
|
|
let job_ids_json: Option<Option<Value>> = sqlx::query_scalar(
|
|
"SELECT workflow_as_code_status->'_checkpoint'->'pending_steps'->'job_ids' \
|
|
FROM v2_job_status WHERE id = $1",
|
|
)
|
|
.bind(&parent_job)
|
|
.fetch_optional(db)
|
|
.await?;
|
|
if let Some(Some(job_ids)) = job_ids_json {
|
|
if let Ok(Some(_)) = handle_wac_child_completion(
|
|
db,
|
|
&job.id,
|
|
parent_job,
|
|
&job.workspace_id,
|
|
downstream_result,
|
|
false,
|
|
job_ids,
|
|
)
|
|
.await
|
|
{
|
|
if let Some(done_tx) = done_tx {
|
|
done_tx
|
|
.send(())
|
|
.expect("done receiver should still be alive");
|
|
}
|
|
return Ok(None);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
return Ok(None);
|
|
}
|
|
|
|
/// Handle a WAC v2 child job completion.
|
|
/// Returns Ok(Some(())) if the parent was a WAC job and was handled,
|
|
/// Ok(None) if the parent is not a WAC job (caller should fall through).
|
|
///
|
|
/// CONCURRENCY: Multiple parallel children may complete simultaneously on
|
|
/// different workers. We use atomic SQL operations throughout:
|
|
/// - `completed_steps` is merged via `jsonb_set(... || jsonb_build_object(...))`
|
|
/// — PostgreSQL serialises concurrent UPDATEs on the same row, so each
|
|
/// worker sees the previous worker's writes.
|
|
/// - The suspend counter (set to N at dispatch time) is decremented atomically
|
|
/// with `RETURNING` to determine the "all done" condition.
|
|
pub(crate) async fn handle_wac_child_completion(
|
|
db: &DB,
|
|
child_job_id: &Uuid,
|
|
parent_job_id: Uuid,
|
|
workspace_id: &str,
|
|
result: Arc<Box<RawValue>>,
|
|
success: bool,
|
|
job_ids_value: Value,
|
|
) -> error::Result<Option<()>> {
|
|
let job_ids = match job_ids_value {
|
|
Value::Object(m) => m,
|
|
_ => return Ok(None), // Not a WAC parent or no pending steps
|
|
};
|
|
|
|
let child_id_str = child_job_id.to_string();
|
|
let step_key = job_ids.iter().find_map(|(key, val)| {
|
|
if val.as_str() == Some(&child_id_str) {
|
|
Some(key.clone())
|
|
} else {
|
|
None
|
|
}
|
|
});
|
|
|
|
let step_key = match step_key {
|
|
Some(k) => k,
|
|
None => {
|
|
if !success {
|
|
// No step key and failed — can't store error, fail parent immediately
|
|
tracing::error!(
|
|
parent_job = %parent_job_id,
|
|
child_job = %child_job_id,
|
|
"WAC v2 child job failed but no step key found, failing parent"
|
|
);
|
|
sqlx::query!(
|
|
"UPDATE v2_job_queue SET suspend = 0, suspend_until = NULL WHERE id = $1",
|
|
parent_job_id,
|
|
)
|
|
.execute(db)
|
|
.await?;
|
|
let parent_mini = get_mini_completed_job(&parent_job_id, workspace_id, db).await?;
|
|
if let Some(parent_mini) = parent_mini {
|
|
let child_err: Value =
|
|
serde_json::from_str(result.get()).unwrap_or(Value::Null);
|
|
let err_value = json!({
|
|
"message": format!("WAC child job {} failed (no step key)", child_job_id),
|
|
"error": child_err,
|
|
});
|
|
let _ = windmill_queue::add_completed_job_error(
|
|
db,
|
|
&parent_mini,
|
|
0,
|
|
None,
|
|
err_value,
|
|
"wac_child_handler",
|
|
false,
|
|
None,
|
|
)
|
|
.await;
|
|
}
|
|
return Ok(Some(()));
|
|
}
|
|
tracing::warn!(
|
|
parent_job = %parent_job_id,
|
|
child_job = %child_job_id,
|
|
"WAC v2 child completed but no matching step key found in checkpoint, decrementing suspend to avoid parent hang"
|
|
);
|
|
// Still decrement suspend so the parent doesn't hang indefinitely
|
|
let _ = sqlx::query_scalar!(
|
|
"UPDATE v2_job_queue \
|
|
SET suspend = GREATEST(suspend - 1, 0) \
|
|
WHERE id = $1 \
|
|
RETURNING suspend",
|
|
parent_job_id,
|
|
)
|
|
.fetch_optional(db)
|
|
.await?;
|
|
return Ok(Some(()));
|
|
}
|
|
};
|
|
|
|
// Build result — wrap errors with _error marker so workflow try/catch can handle them
|
|
let result_value: Value = if success {
|
|
serde_json::from_str(result.get()).unwrap_or(Value::Null)
|
|
} else {
|
|
let child_err: Value = serde_json::from_str(result.get()).unwrap_or(Value::Null);
|
|
tracing::info!(
|
|
parent_job = %parent_job_id,
|
|
child_job = %child_job_id,
|
|
step_key = %step_key,
|
|
"WAC v2 child job failed, storing error for workflow try/catch"
|
|
);
|
|
windmill_common::wac::wac_failure_record(
|
|
&step_key,
|
|
Some(&child_job_id.to_string()),
|
|
&child_err,
|
|
)
|
|
};
|
|
|
|
tracing::info!(
|
|
parent_job = %parent_job_id,
|
|
child_job = %child_job_id,
|
|
step_key = %step_key,
|
|
success = success,
|
|
"WAC v2 child job completed"
|
|
);
|
|
|
|
// Use a transaction to ensure completed_steps merge + suspend decrement
|
|
// are atomic. Without this, a crash between the two could strand the parent.
|
|
let result_json = serde_json::to_value(&result_value)
|
|
.map_err(|e| error::Error::InternalErr(format!("Failed to serialize step result: {e}")))?;
|
|
|
|
let mut tx = db.begin().await?;
|
|
|
|
// Merge the completed step into the checkpoint.
|
|
// Uses `|| jsonb_build_object(key, value)` so concurrent children on
|
|
// different workers don't overwrite each other — PostgreSQL serialises
|
|
// concurrent UPDATEs on the same row and each sees the previous write.
|
|
sqlx::query(
|
|
"UPDATE v2_job_status SET workflow_as_code_status = jsonb_set(
|
|
workflow_as_code_status,
|
|
'{_checkpoint,completed_steps}',
|
|
COALESCE(workflow_as_code_status->'_checkpoint'->'completed_steps', '{}'::jsonb)
|
|
|| jsonb_build_object($2::text, $3::jsonb)
|
|
) WHERE id = $1",
|
|
)
|
|
.bind(&parent_job_id)
|
|
.bind(&step_key)
|
|
.bind(&result_json)
|
|
.execute(&mut *tx)
|
|
.await
|
|
.map_err(|e| error::Error::InternalErr(format!("Failed to add WAC completed step: {e}")))?;
|
|
|
|
// Decrement the suspend counter. The counter was set to N (number of
|
|
// children) at dispatch time. When it reaches 0 all children are done.
|
|
// Keep suspend_until non-null so the suspended pull query
|
|
// (`WHERE suspend_until IS NOT NULL AND suspend <= 0`) picks up the parent.
|
|
let new_suspend: Option<i32> = sqlx::query_scalar!(
|
|
"UPDATE v2_job_queue \
|
|
SET suspend = GREATEST(suspend - 1, 0) \
|
|
WHERE id = $1 \
|
|
RETURNING suspend",
|
|
parent_job_id,
|
|
)
|
|
.fetch_optional(&mut *tx)
|
|
.await?;
|
|
|
|
let all_done = new_suspend == Some(0);
|
|
|
|
if all_done {
|
|
// Clear pending_steps from checkpoint since all children are complete.
|
|
// This is cosmetic — the next replay will overwrite it anyway — but
|
|
// keeps the checkpoint clean for frontend display.
|
|
let _ = sqlx::query(
|
|
"UPDATE v2_job_status SET workflow_as_code_status = \
|
|
workflow_as_code_status #- '{_checkpoint,pending_steps}' \
|
|
WHERE id = $1",
|
|
)
|
|
.bind(&parent_job_id)
|
|
.execute(&mut *tx)
|
|
.await;
|
|
}
|
|
|
|
tx.commit().await?;
|
|
|
|
if all_done {
|
|
tracing::info!(
|
|
parent_job = %parent_job_id,
|
|
"WAC v2 all child jobs completed, unsuspending parent"
|
|
);
|
|
WAC_SUSPEND_READY.store(true, Ordering::Relaxed);
|
|
}
|
|
|
|
Ok(Some(()))
|
|
}
|
|
|
|
pub async fn handle_non_flow_job_error(
|
|
db: &DB,
|
|
job: &MiniCompletedJob,
|
|
mem_peak: i32,
|
|
canceled_by: Option<CanceledBy>,
|
|
err_string: String,
|
|
err_json: Value,
|
|
worker_name: &str,
|
|
) -> Result<WrappedError, Error> {
|
|
append_logs(
|
|
&job.id,
|
|
&job.workspace_id,
|
|
format!("Unexpected error during job execution:\n{err_string}"),
|
|
&db.into(),
|
|
)
|
|
.await;
|
|
add_completed_job_error(
|
|
db,
|
|
job,
|
|
mem_peak,
|
|
canceled_by,
|
|
err_json,
|
|
worker_name,
|
|
false,
|
|
None,
|
|
)
|
|
.await
|
|
}
|
|
|
|
#[tracing::instrument(name = "job_error", level = "info", skip_all, fields(job_id = %job.id))]
|
|
pub async fn handle_job_error(
|
|
db: &DB,
|
|
client: &AuthedClient,
|
|
job: &MiniCompletedJob,
|
|
mem_peak: i32,
|
|
canceled_by: Option<CanceledBy>,
|
|
err: Error,
|
|
unrecoverable: bool,
|
|
same_worker_tx: Option<&SameWorkerSender>,
|
|
worker_dir: &str,
|
|
worker_name: &str,
|
|
job_completed_tx: JobCompletedSender,
|
|
killpill_rx: &tokio::sync::broadcast::Receiver<()>,
|
|
#[cfg(feature = "benchmark")] bench: &mut BenchmarkIter,
|
|
) {
|
|
let err_string = format!("{}: {}", err.name(), err.to_string());
|
|
let err_json = error_to_value(&err);
|
|
|
|
let update_job_future = || async {
|
|
handle_non_flow_job_error(
|
|
db,
|
|
job,
|
|
mem_peak,
|
|
canceled_by.clone(),
|
|
err_string,
|
|
err_json.clone(),
|
|
worker_name,
|
|
)
|
|
.warn_after_seconds(10)
|
|
.await
|
|
};
|
|
|
|
let update_job_future = if job.is_flow_step() || job.is_flow() {
|
|
let (flow, job_status_to_update) = if let Some(parent_job_id) = job.parent_job {
|
|
if let Err(e) = update_job_future().await {
|
|
tracing::error!(
|
|
"error updating job future for job {} for handle_job_error: {e:#}",
|
|
job.id
|
|
);
|
|
}
|
|
(parent_job_id, job.id)
|
|
} else {
|
|
(job.id, Uuid::nil())
|
|
};
|
|
|
|
let wrapped_error = WrappedError { error: err_json.clone() };
|
|
tracing::error!(parent_flow = %flow, subflow = %job_status_to_update, "handle job error, updating flow status: {err_json:?}");
|
|
let updated_flow = update_flow_status_after_job_completion(
|
|
db,
|
|
client,
|
|
flow,
|
|
&job_status_to_update,
|
|
&job.workspace_id,
|
|
false,
|
|
canceled_by.clone(),
|
|
Arc::new(serde_json::value::to_raw_value(&wrapped_error).unwrap()),
|
|
None,
|
|
unrecoverable,
|
|
&same_worker_tx.expect(SAME_WORKER_REQUIREMENTS).clone(),
|
|
worker_dir,
|
|
None,
|
|
worker_name,
|
|
job_completed_tx.clone(),
|
|
None,
|
|
killpill_rx,
|
|
#[cfg(feature = "benchmark")]
|
|
bench,
|
|
)
|
|
.await;
|
|
|
|
if let Err(err) = updated_flow {
|
|
if let Some(parent_job_id) = job.parent_job {
|
|
if let Ok(Some(parent_job)) =
|
|
get_mini_completed_job(&parent_job_id, &job.workspace_id, db)
|
|
.warn_after_seconds(10)
|
|
.await
|
|
{
|
|
let e = json!({"message": err.to_string(), "name": "InternalErr"});
|
|
append_logs(
|
|
&parent_job.id,
|
|
&job.workspace_id,
|
|
format!("Unexpected error during flow job error handling:\n{err}"),
|
|
&db.into(),
|
|
)
|
|
.await;
|
|
let _ = add_completed_job_error(
|
|
db,
|
|
&parent_job,
|
|
mem_peak,
|
|
canceled_by.clone(),
|
|
e,
|
|
worker_name,
|
|
false,
|
|
None,
|
|
)
|
|
.warn_after_seconds(10)
|
|
.await;
|
|
}
|
|
}
|
|
}
|
|
|
|
None
|
|
} else {
|
|
Some(update_job_future)
|
|
};
|
|
if let Some(f) = update_job_future {
|
|
let _ = f().await;
|
|
}
|
|
}
|
|
|
|
#[derive(Debug, Serialize)]
|
|
pub struct SerializedError {
|
|
pub message: String,
|
|
pub name: String,
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
pub step_id: Option<String>,
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
pub exit_code: Option<i32>,
|
|
}
|
|
pub fn extract_error_value(
|
|
program: &str,
|
|
log_lines: &str,
|
|
i: i32,
|
|
step_id: Option<String>,
|
|
) -> Box<RawValue> {
|
|
return to_raw_value(&SerializedError {
|
|
message: format!(
|
|
"exit code for \"{program}\": {i}, last log lines:\n{}",
|
|
ANSI_ESCAPE_RE.replace_all(log_lines.trim(), "").to_string()
|
|
),
|
|
name: "ExecutionErr".to_string(),
|
|
step_id,
|
|
exit_code: Some(i),
|
|
});
|
|
}
|
|
|
|
#[cfg(all(test, feature = "enterprise", feature = "private"))]
|
|
mod git_sync_pr_tests {
|
|
use super::{git_sync_deploy_pr_head_branch, git_sync_push_result_pushed};
|
|
|
|
#[test]
|
|
fn user_and_group_items_get_no_promotion_branch() {
|
|
for path_type in ["user", "group"] {
|
|
assert_eq!(
|
|
git_sync_deploy_pr_head_branch(
|
|
"ws",
|
|
None,
|
|
None,
|
|
"main",
|
|
true,
|
|
false,
|
|
"u/someone",
|
|
"",
|
|
path_type
|
|
),
|
|
None
|
|
);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn scope_note_lists_filters() {
|
|
use super::format_git_sync_scope_note;
|
|
assert_eq!(format_git_sync_scope_note(&[], &[]), None);
|
|
assert_eq!(
|
|
format_git_sync_scope_note(&["f/**".into()], &[]).unwrap(),
|
|
"\n\nOnly files matching this repository's sync filters deploy on merge: `f/**`."
|
|
);
|
|
assert_eq!(
|
|
format_git_sync_scope_note(&["f/**".into(), "u/**".into()], &["f/pat/**".into()])
|
|
.unwrap(),
|
|
"\n\nOnly files matching this repository's sync filters deploy on merge: `f/**`, `u/**` (excluding `f/pat/**`)."
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn push_result_pushed_flag() {
|
|
assert_eq!(
|
|
git_sync_push_result_pushed(r#"{"pushed": true}"#),
|
|
Some(true)
|
|
);
|
|
assert_eq!(
|
|
git_sync_push_result_pushed(r#"{"pushed": false}"#),
|
|
Some(false)
|
|
);
|
|
// Older hub script versions return null / no flag: undetermined.
|
|
assert_eq!(git_sync_push_result_pushed("null"), None);
|
|
assert_eq!(git_sync_push_result_pushed(r#"{"other": 1}"#), None);
|
|
assert_eq!(git_sync_push_result_pushed("not json"), None);
|
|
}
|
|
|
|
#[test]
|
|
fn fork_branch_wins_and_strips_the_id_prefix() {
|
|
// Generated fork id: branch suffix drops the wm-fork- prefix.
|
|
assert_eq!(
|
|
git_sync_deploy_pr_head_branch(
|
|
"wm-fork-abc",
|
|
Some("prod"),
|
|
None,
|
|
"main",
|
|
false,
|
|
false,
|
|
"",
|
|
"",
|
|
""
|
|
),
|
|
Some("wm-fork/main/abc".to_string())
|
|
);
|
|
// Dev workspace (prefix-less id, detected via parent): verbatim suffix.
|
|
assert_eq!(
|
|
git_sync_deploy_pr_head_branch(
|
|
"staging",
|
|
Some("prod"),
|
|
None,
|
|
"main",
|
|
false,
|
|
false,
|
|
"",
|
|
"",
|
|
""
|
|
),
|
|
Some("wm-fork/main/staging".to_string())
|
|
);
|
|
// Orphaned fork (parent deleted): the id prefix still identifies it.
|
|
assert_eq!(
|
|
git_sync_deploy_pr_head_branch(
|
|
"wm-fork-abc",
|
|
None,
|
|
None,
|
|
"main",
|
|
true,
|
|
false,
|
|
"f/x/y",
|
|
"",
|
|
"script"
|
|
),
|
|
Some("wm-fork/main/abc".to_string())
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn promotion_branch_matches_the_hub_script_formula() {
|
|
// Per-item form: wm_deploy/<ws>/<path_type>/<path with / -> __>.
|
|
assert_eq!(
|
|
git_sync_deploy_pr_head_branch(
|
|
"dev",
|
|
None,
|
|
None,
|
|
"main",
|
|
true,
|
|
false,
|
|
"f/folder/my_script",
|
|
"",
|
|
"script"
|
|
),
|
|
Some("wm_deploy/dev/script/f__folder__my_script".to_string())
|
|
);
|
|
// Grouped-by-folder form: first two path segments joined by __.
|
|
assert_eq!(
|
|
git_sync_deploy_pr_head_branch(
|
|
"dev",
|
|
None,
|
|
None,
|
|
"main",
|
|
true,
|
|
true,
|
|
"f/folder/my_script",
|
|
"",
|
|
"script"
|
|
),
|
|
Some("wm_deploy/dev/f__folder".to_string())
|
|
);
|
|
// Renamed object: falls back to the parent path.
|
|
assert_eq!(
|
|
git_sync_deploy_pr_head_branch(
|
|
"dev",
|
|
None,
|
|
None,
|
|
"main",
|
|
true,
|
|
false,
|
|
"",
|
|
"f/folder/old",
|
|
"script"
|
|
),
|
|
Some("wm_deploy/dev/script/f__folder__old".to_string())
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn no_branch_when_deploy_stays_on_base() {
|
|
// Workspace-wide mode commits straight to the tracked branch.
|
|
assert_eq!(
|
|
git_sync_deploy_pr_head_branch(
|
|
"dev", None, None, "main", false, false, "f/x/y", "", "script"
|
|
),
|
|
None
|
|
);
|
|
// Promotion mode but no per-item ref (e.g. user/group objects).
|
|
assert_eq!(
|
|
git_sync_deploy_pr_head_branch("dev", None, None, "main", true, false, "", "", "user"),
|
|
None
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn dev_workspace_label_branch_wins() {
|
|
// Dev workspaces deploy to their environment-label branch verbatim.
|
|
assert_eq!(
|
|
git_sync_deploy_pr_head_branch(
|
|
"staging-ws",
|
|
Some("prod"),
|
|
Some("staging"),
|
|
"main",
|
|
false,
|
|
false,
|
|
"",
|
|
"",
|
|
""
|
|
),
|
|
Some("staging".to_string())
|
|
);
|
|
// Label present even on a wm-fork-prefixed id: label still wins.
|
|
assert_eq!(
|
|
git_sync_deploy_pr_head_branch(
|
|
"wm-fork-x",
|
|
Some("prod"),
|
|
Some("dev"),
|
|
"main",
|
|
false,
|
|
false,
|
|
"",
|
|
"",
|
|
""
|
|
),
|
|
Some("dev".to_string())
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn dev_workspace_promotion_uses_wm_deploy_branch() {
|
|
// Promotion on: a dev workspace gets per-item wm_deploy/** branches
|
|
// (namespaced by its own id), not its env-label branch.
|
|
assert_eq!(
|
|
git_sync_deploy_pr_head_branch(
|
|
"dev",
|
|
Some("prod"),
|
|
Some("dev"),
|
|
"main",
|
|
true,
|
|
false,
|
|
"f/folder/my_script",
|
|
"",
|
|
"script"
|
|
),
|
|
Some("wm_deploy/dev/script/f__folder__my_script".to_string())
|
|
);
|
|
// Per-folder form still honored for a promotion dev workspace.
|
|
assert_eq!(
|
|
git_sync_deploy_pr_head_branch(
|
|
"dev",
|
|
Some("prod"),
|
|
Some("dev"),
|
|
"main",
|
|
true,
|
|
true,
|
|
"f/folder/my_script",
|
|
"",
|
|
"script"
|
|
),
|
|
Some("wm_deploy/dev/f__folder".to_string())
|
|
);
|
|
// Promotion off: the env-label branch still wins.
|
|
assert_eq!(
|
|
git_sync_deploy_pr_head_branch(
|
|
"dev",
|
|
Some("prod"),
|
|
Some("dev"),
|
|
"main",
|
|
false,
|
|
false,
|
|
"f/x/y",
|
|
"",
|
|
"script"
|
|
),
|
|
Some("dev".to_string())
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn dev_promotion_user_group_items_open_no_pr() {
|
|
// User/group objects get no wm_deploy branch even on a dev workspace; the
|
|
// CLI isolates them to the env-label branch, so the backend opens no PR
|
|
// (never a PR from the env-label branch into the parent for these).
|
|
for path_type in ["user", "group"] {
|
|
assert_eq!(
|
|
git_sync_deploy_pr_head_branch(
|
|
"dev",
|
|
Some("prod"),
|
|
Some("dev"),
|
|
"main",
|
|
true,
|
|
false,
|
|
"u/alice",
|
|
"",
|
|
path_type
|
|
),
|
|
None
|
|
);
|
|
}
|
|
}
|
|
}
|