mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-08-22 08:02:19 +00:00
a6d4390790
* feat: workflow-as-code v2 with @task decorator API
Replace ctx.step("name", "script") API with @task decorators where
functions are called directly. Users no longer need to pass WorkflowCtx
or use string-based step names/script paths.
Python: @task decorator with contextvars-based implicit context
TypeScript: task() wrapper with module-level context variable
Parsers: detect @task function calls instead of ctx.step() calls
Worker: updated wrappers to set implicit context
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* feat: WAC v2 checkpoint/replay with _executing_key child dispatch
- Rust-side orchestration: parent dispatches child jobs, suspends, resumes on completion
- _executing_key in checkpoint tells child which step to execute directly
- task() throws StepSuspend(mode="step_complete") after executing target step
- result_processor handles child completion and updates parent checkpoint
- WacGraph.svelte for runtime execution visualization
- Sequential and parallel workflows tested end-to-end
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* fix: WAC v2 bundle cache, globalThis ctx sharing, description optional
- Disable bun bundle caching for WAC v2 scripts (wrapper needs
windmill-client from node_modules, not available in bundle mode)
- Use Reflect.set/get(globalThis, "__wmill_wf_ctx") to share workflow
context across dual module instances (wrapper vs user script)
- Never-resolving thenable for non-matching steps in child job mode
prevents Promise.all race conditions
- Make description field optional in NewScript API (defaults to "")
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* feat: add step() primitive for inline checkpointed steps
step() executes a function inline (no child job) and persists the result
to the checkpoint. On replay, the cached value is returned — ensuring
deterministic behavior for non-deterministic operations like Date.now()
or Math.random().
- TypeScript: step(name, fn) — executes inline, throws StepSuspend with
mode "inline_checkpoint" to persist before continuing
- Rust: InlineCheckpoint variant in WacOutput, saves to checkpoint and
resets running=false for immediate re-pickup (no zombie wait)
- Shared step counter between task() and step() via _allocKey()
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* feat: add Python WAC v2 support with task(), step(), workflow()
- Python SDK: WorkflowCtx with _executing_key child mode, _alloc_key
shared counter, _run_inline_step for step(), _execute_directly and
_never_resolve for child mode, step() async function
- Python executor: WAC v2 detection, checkpoint.json writing, WAC
wrapper.py generation calling _run_workflow(), post-execution hook
into shared handle_wac_v2_output()
- Make handle_wac_v2_output pub so both bun and python executors share
the same dispatch/suspend/inline-checkpoint logic
- 17 Python tests covering dispatch, replay, parallel, conditional,
inline checkpoint, and child mode
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* chore: update sqlx prepared queries
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* fix: WacGraph Tooltip→Popover, simplify wacToFlow parsers
- Fix type error: Tooltip doesn't accept text snippet, use Popover
- Extract shared helpers for task matching and block collection
- Replace linear tasks.find() with Map lookups
- Remove mutable module-level counter
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* fix: Box::pin WAC v2 output handler to prevent stack overflow
handle_python_job's async state machine was too large when combined
with handle_wac_v2_output. Box::pin heap-allocates the future.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* fix: merge WAC v1 and v2 task decorators to preserve backward compat
The v2 @task decorator was shadowing the v1 one, breaking WAC v1
scripts that rely on HTTP-based dispatch via /workflow_as_code/ API.
The merged decorator handles three modes:
- v2: inside @workflow context → checkpoint/replay dispatch
- v1: WM_JOB_ID set, no @workflow → HTTP API dispatch + wait_job
- standalone: no Windmill env → execute function body directly
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* fix: skip no_main_func detection for WAC v2 scripts in TS and Python parsers
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* fix: prevent empty/noop dispatch causing infinite requeue loop
- Validate steps.len() > 0 in WAC dispatch handler (issue 3)
- Replace noop StepSuspend throw with never-resolving promise so it
can't reach the backend as an empty dispatch (issue 4)
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* fix: Python task wrapper now converts positional args to kwargs in v2 mode
Previously only **kwargs were passed to _next_step(), silently dropping
positional arguments. Extract shared _merge_args() helper used by both
v1 and v2 paths.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* fix: replace unwrap() with proper error propagation in WAC arg serialization
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* fix: add workspace_id filter to v2_job queries in WAC dispatch
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* fix: prevent race condition in WAC child dispatch
Restructure dispatch to save checkpoint + suspend parent + seed child
checkpoints in a single transaction BEFORE pushing child jobs. This
ensures a fast child can't complete before the parent is suspended.
Also wrap InlineCheckpoint save + running reset in a transaction to
prevent corrupted state on crash.
Use ULID for pre-generated child job IDs (consistent with rest of API).
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* fix: include step key and child job ID in WAC error propagation
Move step_key lookup before the success check so failed child errors
include which task failed, the child job ID, and the original error.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* docs: document WAC determinism contract and step dispatch semantics
- Document that workflow functions must be deterministic across replays
- Document that WacStepDispatch.script/args are metadata, not dispatch targets
- Add comments on counter-based key allocation
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* fix: tighten WAC v2 detection to reduce false positives
Replace naive substring matching with line-aware checks that skip
comments and look for specific patterns:
- TS: import from "windmill-client" containing workflow/task
- Python: @workflow and @task decorators with wmill import
Extracted shared helpers in wac_executor.rs used by both executors.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* fix: show failed steps in WacGraph when workflow completes with errors
When flowDone is true and a pending step isn't in completedSteps,
mark it as 'failed' instead of 'running'. The failed state CSS and
XCircle icon were already defined but never triggered.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* fix: unsuspend and fail parent when WAC child push fails
Previously if a child push failed mid-batch, the parent remained
suspended with suspend = num_steps but fewer children, hanging until
the 14-day timeout. Now the push loop catches errors and unsuspends
the parent before returning the error.
Also adds source hash validation: if the script content changes between
replays, the job fails with a clear error instead of silently feeding
stale checkpoint data into wrong steps.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* fix: clear suspend_until when unsuspending WAC parent
Set suspend_until = NULL alongside suspend = 0 in both the child
failure and all-children-complete paths, so the parent doesn't rely
on subtle pull query invariants to be re-picked-up.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* test: add exhaustive edge case tests for WAC v2 SDK
fix: make TS task wrapper non-async to fix unawaited task flush
The async wrapper caused microtask-based thenable auto-resolution that
fired .then() and threw StepSuspend before _flushPending() could capture
unawaited steps — making the flush mechanism completely broken. Now the
thenable is returned directly without async wrapping. Backward compatible
with v1 (all code paths still return awaitables).
Tests added (59 TS + 66 Python) covering: full sequential lifecycle,
step after parallel, parallel after parallel, conditional on step result,
empty/single-task workflows, 10+ steps, falsy value preservation, inline
steps, mixed step/task, unawaited flush, child mode with parallel,
key determinism, large parallel groups, and complex mixed patterns.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* fix: atomic checkpoint updates to prevent parallel child race condition
Replace read-modify-write pattern in handle_wac_child_completion with
atomic SQL operations:
- completed_steps merged via jsonb_set(... || jsonb_build_object(...))
so concurrent children on different workers don't overwrite each other
- suspend counter decremented atomically with RETURNING to determine
"all done" condition (instead of checking completed_steps in memory)
- suspend_until cleared in the same atomic decrement statement
Before this fix, two parallel children completing simultaneously could
both load the same checkpoint, each add their step, and save — the
second write would overwrite the first, silently losing a child result
and leaving the parent suspended forever.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* fix: cancel already-pushed children on partial WAC dispatch failure
When pushing child jobs sequentially, if pushing child N fails, children
1..N-1 are already running. Previously the error handler only unsuspended
the parent, leaving orphaned children that would complete and corrupt the
checkpoint state (decrementing suspend on an already-unsuspended parent,
potentially causing duplicate step execution on re-run).
Now on partial failure:
1. Cancel all already-pushed children (prevents them from completing
and corrupting checkpoint state)
2. Clear pending_steps from checkpoint (so parent doesn't think
children are outstanding on re-run)
3. Then unsuspend parent (so the error propagates)
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* fix: skip WAC duration write and child check for non-WAC parents
The duration write to workflow_as_code_status was running for every
non-flow child with a parent (error handlers, success handlers,
run_script children), even though it was only intended for WAC jobs.
Add WHERE workflow_as_code_status IS NOT NULL to skip non-WAC parents
entirely. Piggyback RETURNING pending_steps.job_ids on the same query
so WAC v2 child completion needs zero extra DB round-trips on the
success path.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* fix: seed child checkpoint in same transaction as push
The child checkpoint insert was happening before the child job was
pushed, violating the FK constraint on v2_job_status. Move it into
the push transaction so the job row exists and the child can't be
picked up before its checkpoint is ready.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* fix: set running=false when WAC parent suspends for child dispatch
The parent job kept running=true after suspending, so workers wouldn't
pick it up when children completed and suspend reached 0. The parent
only advanced when the zombie job detector reset it (~90s). Now the
dispatch suspend sets running=false so the parent is immediately
eligible for pickup.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* fix: WAC parent suspend/unsuspend lifecycle
Keep running=true when suspending the parent so the normal pull query
(WHERE running=false) never picks it up. Keep suspend_until non-null
when decrementing suspend to 0 so the suspended pull query
(WHERE suspend_until IS NOT NULL AND suspend<=0) picks it up.
Previously: setting running=false caused infinite restart loops because
the normal pull query has no suspend check and would immediately re-pick
the parent. Clearing suspend_until on the last child prevented the
suspended pull from ever seeing it, requiring the 90s zombie detector.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* feat: add approval primitive, flow child completion, timeline fixes for WAC v2
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* feat: add error propagation, task options, sleep, and parallel for WAC v2
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* test: fix python SDK tests to use name-based keys and add new test coverage
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* fix: address WAC v2 review findings (sleep timing, error marker, atomicity)
- Fix sleep using suspend=1 instead of 0 to enforce actual delay
- Add approval/sleep resume injection to Python executor
- Fix TS SDK concurrency_limit mapping (was reading wrong property)
- Namespace error marker as __wmill_error to avoid user data collision
- Wrap child completion SQL in transaction for atomicity
- Decrement suspend even when step key is missing (prevents hang)
- Expand TASK_RE to handle export const, let, var, generics
- Validate step key uniqueness before dispatch
- Log warning on checkpoint deserialization failure
- Remove unimplemented delete_after_use from SDKs
- Add TaskError exception class to Python SDK with diagnostic context
- Fix extra positional args handling and add functools.wraps
- Improve getParamNames to handle typed/destructured params
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* sqlx
* sqlx
* test: add WAC v1 e2e integration tests for TS and Python
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* fix: revert fake test versions in typescript-client
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* refactor: remove unused WacGraph component and strip wacToFlow to isWorkflowAsCode
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* refactor: extract shared approval/sleep resume logic into wac_executor
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
575 lines
20 KiB
Rust
575 lines
20 KiB
Rust
use std::collections::HashMap;
|
|
|
|
use futures::future::BoxFuture;
|
|
use futures::{FutureExt, StreamExt};
|
|
use reqwest::Client;
|
|
use serde_json::{json, value::RawValue, Value};
|
|
use windmill_common::client::AuthedClient;
|
|
use windmill_common::error::to_anyhow;
|
|
use windmill_common::worker::{Connection, SqlResultCollectionStrategy};
|
|
use windmill_common::{error::Error, worker::to_raw_value};
|
|
use windmill_object_store::convert_json_line_stream;
|
|
use windmill_parser_sql::{
|
|
parse_bigquery_sig, parse_db_resource, parse_s3_mode, parse_sql_blocks,
|
|
parse_sql_statement_named_params,
|
|
};
|
|
use windmill_queue::CanceledBy;
|
|
|
|
use serde::Deserialize;
|
|
|
|
use crate::common::{build_args_values, resolve_job_timeout};
|
|
use crate::common::{
|
|
build_http_client, get_reserved_variables, s3_mode_args_to_worker_data, OccupancyMetrics,
|
|
S3ModeWorkerData,
|
|
};
|
|
use crate::handle_child::run_future_with_polling_update_job_poller;
|
|
use crate::sanitized_sql_params::sanitize_and_interpolate_unsafe_sql_args;
|
|
|
|
use gcp_auth::{AuthenticationManager, CustomServiceAccount};
|
|
|
|
#[allow(non_snake_case)]
|
|
#[derive(Deserialize)]
|
|
struct BigqueryResponse {
|
|
rows: Option<Vec<BigqueryResponseRow>>,
|
|
totalRows: Option<Value>,
|
|
schema: Option<BigqueryResponseSchema>,
|
|
jobComplete: bool,
|
|
pageToken: Option<String>,
|
|
jobReference: Option<BigQueryResponseJobReference>,
|
|
}
|
|
|
|
#[allow(non_snake_case)]
|
|
#[derive(Deserialize, Clone)]
|
|
struct BigQueryResponseJobReference {
|
|
jobId: String,
|
|
projectId: String,
|
|
location: Option<String>,
|
|
}
|
|
|
|
#[derive(Deserialize)]
|
|
struct BigqueryResponseRow {
|
|
f: Vec<BigqueryResponseValue>,
|
|
}
|
|
|
|
#[derive(Deserialize)]
|
|
struct BigqueryResponseValue {
|
|
v: Value,
|
|
}
|
|
|
|
#[derive(Deserialize)]
|
|
struct BigqueryResponseSchema {
|
|
fields: Vec<BigqueryResponseSchemaField>,
|
|
}
|
|
|
|
#[derive(Deserialize)]
|
|
struct BigqueryResponseSchemaField {
|
|
name: String,
|
|
r#type: String,
|
|
fields: Option<Vec<BigqueryResponseSchemaField>>,
|
|
}
|
|
|
|
#[derive(Deserialize)]
|
|
struct BigqueryErrorResponse {
|
|
error: BigqueryError,
|
|
}
|
|
|
|
#[derive(Deserialize)]
|
|
struct BigqueryError {
|
|
message: String,
|
|
}
|
|
|
|
fn do_bigquery_inner<'a>(
|
|
query: &'a str,
|
|
all_statement_values: &'a HashMap<String, Value>,
|
|
project_id: &'a str,
|
|
token: &'a str,
|
|
timeout_ms: u64,
|
|
column_order: Option<&'a mut Option<Vec<String>>>,
|
|
skip_collect: bool,
|
|
first_row_only: bool,
|
|
http_client: &'a Client,
|
|
s3: Option<S3ModeWorkerData>,
|
|
) -> windmill_common::error::Result<BoxFuture<'a, windmill_common::error::Result<Vec<Box<RawValue>>>>>
|
|
{
|
|
let param_names = parse_sql_statement_named_params(query, '@');
|
|
|
|
let statement_values = all_statement_values
|
|
.iter()
|
|
.filter_map(|(name, val)| {
|
|
if param_names.contains(name) {
|
|
Some(val)
|
|
} else {
|
|
None
|
|
}
|
|
})
|
|
.collect::<Vec<&Value>>();
|
|
|
|
let result_f = async move {
|
|
let response = http_client
|
|
.post(
|
|
"https://bigquery.googleapis.com/bigquery/v2/projects/".to_string()
|
|
+ project_id
|
|
+ "/queries",
|
|
)
|
|
.bearer_auth(token)
|
|
.json(&json!({
|
|
"query": query,
|
|
"useLegacySql": false,
|
|
"maxResults": if first_row_only { 1 } else { 10000 },
|
|
"timeoutMs": timeout_ms,
|
|
"queryParameters": statement_values,
|
|
}))
|
|
.send()
|
|
.await
|
|
.map_err(|e| {
|
|
Error::ExecutionErr(format!("Could not send query to BigQuery API: {}", e))
|
|
})?;
|
|
|
|
match response.error_for_status_ref() {
|
|
Ok(_) => {
|
|
if skip_collect {
|
|
return Ok(vec![]);
|
|
} else {
|
|
let result = response.json::<BigqueryResponse>().await.map_err(|e| {
|
|
Error::ExecutionErr(format!(
|
|
"BigQuery API response could not be parsed: {}",
|
|
e.to_string()
|
|
))
|
|
})?;
|
|
let rows = handle_bigquery_response(&result, &s3, column_order).await?;
|
|
|
|
if let Some(s3) = s3 {
|
|
let cloned_s3 = s3.clone();
|
|
let cloned_http_client = http_client.clone();
|
|
let cloned_token = token.to_string();
|
|
let rows_stream = async_stream::stream! {
|
|
for row in rows.iter() {
|
|
yield Ok::<_, windmill_common::error::Error>(row.clone());
|
|
}
|
|
let mut next_page_token = result.pageToken;
|
|
let Some(job_reference) = result.jobReference.clone() else {
|
|
return;
|
|
};
|
|
while let Some(ref next_page_token_value) = next_page_token {
|
|
let response2 = cloned_http_client
|
|
.get(
|
|
format!("https://bigquery.googleapis.com/bigquery/v2/projects/{}/queries/{}", job_reference.projectId, job_reference.jobId),
|
|
)
|
|
.bearer_auth(cloned_token.as_str())
|
|
.query(&[
|
|
("pageToken", next_page_token_value.as_str()),
|
|
("maxResults", "10000"),
|
|
("timeoutMs", timeout_ms.to_string().as_str()),
|
|
("location", job_reference.location.as_ref().unwrap_or(&"US".to_string()).as_str()),
|
|
])
|
|
.send()
|
|
.await
|
|
.map_err(|e| {
|
|
Error::ExecutionErr(format!("Could not send query to BigQuery API: {}", e))
|
|
})?;
|
|
|
|
if let Err(e) = response2.error_for_status_ref() {
|
|
match response2.json::<BigqueryErrorResponse>().await {
|
|
Ok(bq_err) => {
|
|
yield Err(Error::ExecutionErr(format!(
|
|
"Error from BigQuery API: {}",
|
|
bq_err.error.message
|
|
)))
|
|
.map_err(to_anyhow)?;
|
|
return;
|
|
},
|
|
Err(_) => {
|
|
yield Err(Error::ExecutionErr(format!(
|
|
"Error from BigQuery API could not be parsed: {}",
|
|
e.to_string()
|
|
)))
|
|
.map_err(to_anyhow)?;
|
|
return;
|
|
},
|
|
}
|
|
}
|
|
|
|
let result2 = response2.json::<BigqueryResponse>().await.map_err(|e| {
|
|
Error::ExecutionErr(format!(
|
|
"BigQuery API response could not be parsed: {}",
|
|
e.to_string()
|
|
))
|
|
})?;
|
|
let rows = handle_bigquery_response(&result2, &Some(cloned_s3.clone()), None).await?;
|
|
for row in rows.into_iter() {
|
|
yield Ok::<_, windmill_common::error::Error>(row);
|
|
}
|
|
next_page_token = result2.pageToken;
|
|
}
|
|
};
|
|
|
|
let stream =
|
|
convert_json_line_stream(rows_stream.boxed(), s3.format).await?;
|
|
s3.upload(stream.boxed()).await?;
|
|
|
|
return Ok(vec![to_raw_value(&s3.to_return_s3_obj())]);
|
|
}
|
|
|
|
Ok(rows.iter().map(to_raw_value).collect::<Vec<_>>())
|
|
}
|
|
}
|
|
Err(e) => match response.json::<BigqueryErrorResponse>().await {
|
|
Ok(bq_err) => Err(Error::ExecutionErr(format!(
|
|
"Error from BigQuery API: {}",
|
|
bq_err.error.message
|
|
)))
|
|
.map_err(to_anyhow)?,
|
|
Err(_) => Err(Error::ExecutionErr(format!(
|
|
"Error from BigQuery API could not be parsed: {}",
|
|
e.to_string()
|
|
)))
|
|
.map_err(to_anyhow)?,
|
|
},
|
|
}
|
|
};
|
|
|
|
Ok(result_f.boxed())
|
|
}
|
|
|
|
async fn handle_bigquery_response<'a>(
|
|
result: &BigqueryResponse,
|
|
s3: &Option<S3ModeWorkerData>,
|
|
column_order: Option<&'a mut Option<Vec<String>>>,
|
|
) -> windmill_common::error::Result<Vec<Value>> {
|
|
if !result.jobComplete {
|
|
return Err(Error::ExecutionErr(
|
|
"BigQuery API did not answer query in time".to_string(),
|
|
));
|
|
}
|
|
|
|
if result.rows.is_none() || result.rows.as_ref().unwrap().len() == 0 {
|
|
return Ok(serde_json::from_str("[]").unwrap());
|
|
}
|
|
|
|
if result.schema.is_none() {
|
|
return Err(Error::ExecutionErr(
|
|
"Incomplete response from BigQuery API".to_string(),
|
|
));
|
|
}
|
|
|
|
if s3.is_none()
|
|
&& result
|
|
.totalRows
|
|
.as_ref()
|
|
.unwrap_or(&json!(""))
|
|
.as_str()
|
|
.unwrap_or("")
|
|
.parse::<i64>()
|
|
.unwrap_or(0)
|
|
> 10000
|
|
{
|
|
return Err(Error::ExecutionErr(
|
|
"More than 10000 rows were requested, use LIMIT 10000 to limit the number of rows or use S3 streaming for larger datasets: https://windmill.dev/docs/core_concepts/sql_to_s3_streaming"
|
|
.to_string(),
|
|
));
|
|
}
|
|
|
|
if let Some(column_order) = column_order {
|
|
*column_order = Some(
|
|
result
|
|
.schema
|
|
.as_ref()
|
|
.unwrap()
|
|
.fields
|
|
.iter()
|
|
.map(|x| x.name.clone())
|
|
.collect::<Vec<String>>(),
|
|
);
|
|
}
|
|
|
|
let rows = result
|
|
.rows
|
|
.as_ref()
|
|
.unwrap()
|
|
.iter()
|
|
.map(|row| {
|
|
let mut row_map = serde_json::Map::new();
|
|
row.f
|
|
.iter()
|
|
.zip(result.schema.as_ref().unwrap().fields.iter())
|
|
.for_each(|(field, schema)| {
|
|
row_map.insert(
|
|
schema.name.clone(),
|
|
parse_val(&field.v, &schema.r#type, &schema),
|
|
);
|
|
});
|
|
Value::from(row_map)
|
|
})
|
|
.collect::<Vec<_>>();
|
|
Ok(rows)
|
|
}
|
|
|
|
use windmill_queue::MiniPulledJob;
|
|
|
|
pub async fn do_bigquery(
|
|
job: &MiniPulledJob,
|
|
client: &AuthedClient,
|
|
query: &str,
|
|
conn: &Connection,
|
|
mem_peak: &mut i32,
|
|
canceled_by: &mut Option<CanceledBy>,
|
|
worker_name: &str,
|
|
column_order: &mut Option<Vec<String>>,
|
|
occupancy_metrics: &mut OccupancyMetrics,
|
|
parent_runnable_path: Option<String>,
|
|
) -> windmill_common::error::Result<Box<RawValue>> {
|
|
let bigquery_args = build_args_values(job, client, conn).await?;
|
|
|
|
let inline_db_res_path = parse_db_resource(&query);
|
|
let s3 = parse_s3_mode(&query)?.map(|s3| s3_mode_args_to_worker_data(s3, client.clone(), job));
|
|
|
|
let db_arg = if let Some(inline_db_res_path) = inline_db_res_path {
|
|
Some(
|
|
client
|
|
.get_resource_value_interpolated::<serde_json::Value>(
|
|
&inline_db_res_path,
|
|
Some(job.id.to_string()),
|
|
)
|
|
.await?,
|
|
)
|
|
} else {
|
|
bigquery_args.get("database").cloned()
|
|
};
|
|
|
|
let database = if let Some(db) = db_arg {
|
|
db.to_string()
|
|
} else {
|
|
return Err(Error::BadRequest("Missing database argument".to_string()));
|
|
};
|
|
|
|
let annotations = windmill_common::worker::SqlAnnotations::parse(query);
|
|
let collection_strategy = if annotations.return_last_result {
|
|
SqlResultCollectionStrategy::LastStatementAllRows
|
|
} else {
|
|
annotations.result_collection
|
|
};
|
|
|
|
let service_account = CustomServiceAccount::from_json(&database)
|
|
.map_err(|e| Error::ExecutionErr(e.to_string()))?;
|
|
|
|
let authentication_manager = AuthenticationManager::from(service_account);
|
|
let scopes = &["https://www.googleapis.com/auth/bigquery"];
|
|
let token = authentication_manager
|
|
.get_token(scopes)
|
|
.await
|
|
.map_err(|e| Error::ExecutionErr(e.to_string()))?;
|
|
|
|
let (timeout_duration, _, _) =
|
|
resolve_job_timeout(&conn, &job.workspace_id, job.id, job.timeout).await;
|
|
let timeout_ms = timeout_duration.as_millis() as u64;
|
|
let http_client = build_http_client(timeout_duration)?;
|
|
|
|
let project_id = authentication_manager
|
|
.project_id()
|
|
.await
|
|
.map_err(|e| Error::ExecutionErr(e.to_string()))?;
|
|
|
|
let sig = parse_bigquery_sig(&query)
|
|
.map_err(|x| Error::ExecutionErr(x.to_string()))?
|
|
.args;
|
|
|
|
let reserved_variables =
|
|
get_reserved_variables(job, &client.token, conn, parent_runnable_path).await?;
|
|
|
|
let (query, args_to_skip) = &sanitize_and_interpolate_unsafe_sql_args(
|
|
query,
|
|
&sig,
|
|
&bigquery_args,
|
|
&reserved_variables,
|
|
)?;
|
|
|
|
let queries = parse_sql_blocks(query);
|
|
|
|
let mut statement_values: HashMap<String, Value> = HashMap::new();
|
|
|
|
for arg in &sig {
|
|
if args_to_skip.contains(&arg.name) {
|
|
continue;
|
|
}
|
|
let arg_t = arg.otyp.clone().unwrap_or_else(|| "string".to_string());
|
|
let arg_n = arg.clone().name;
|
|
let arg_v = bigquery_args.get(&arg.name).cloned().unwrap_or(json!(""));
|
|
let bigquery_v = if arg_t.ends_with("[]") {
|
|
let base_type = arg_t.strip_suffix("[]").unwrap_or(&arg_t);
|
|
json!({
|
|
"name": arg.name,
|
|
"parameterType": {
|
|
"type": "ARRAY",
|
|
"arrayType": {
|
|
"type": base_type.to_uppercase()
|
|
}
|
|
},
|
|
"parameterValue": {
|
|
"arrayValues": bigquery_args
|
|
.get(&arg.name)
|
|
.unwrap_or(&json!([]))
|
|
.as_array()
|
|
.unwrap_or(&vec![])
|
|
.iter()
|
|
.map(|x| {
|
|
convert_val(base_type.to_string(), x.clone())
|
|
})
|
|
.collect::<Vec<Value>>()
|
|
}
|
|
})
|
|
} else {
|
|
json!({
|
|
"name": arg_n,
|
|
"parameterType": {
|
|
"type": arg_t.to_uppercase()
|
|
},
|
|
"parameterValue": {
|
|
"value": convert_val(arg_t, arg_v),
|
|
}
|
|
})
|
|
};
|
|
|
|
statement_values.insert(arg_n, bigquery_v);
|
|
}
|
|
|
|
let result_f = async move {
|
|
let mut results = vec![];
|
|
for (i, q) in queries.iter().enumerate() {
|
|
let result = do_bigquery_inner(
|
|
q,
|
|
&statement_values,
|
|
&project_id,
|
|
token.as_str(),
|
|
timeout_ms,
|
|
if i == queries.len() - 1
|
|
&& s3.is_none()
|
|
&& collection_strategy.collect_last_statement_only(queries.len())
|
|
&& !collection_strategy.collect_scalar()
|
|
{
|
|
Some(column_order)
|
|
} else {
|
|
None
|
|
},
|
|
collection_strategy.collect_last_statement_only(queries.len())
|
|
&& i < queries.len() - 1,
|
|
collection_strategy.collect_first_row_only(),
|
|
&http_client,
|
|
s3.clone(),
|
|
)?
|
|
.await?;
|
|
results.push(result);
|
|
}
|
|
|
|
collection_strategy.collect(results)
|
|
};
|
|
|
|
let r = run_future_with_polling_update_job_poller(
|
|
job.id,
|
|
job.timeout,
|
|
conn,
|
|
mem_peak,
|
|
canceled_by,
|
|
result_f,
|
|
worker_name,
|
|
&job.workspace_id,
|
|
&mut Some(occupancy_metrics),
|
|
Box::pin(futures::stream::once(async { 0 })),
|
|
)
|
|
.await?;
|
|
|
|
*mem_peak = (r.get().len() / 1000) as i32;
|
|
Ok(r)
|
|
}
|
|
|
|
fn convert_val(arg_t: String, arg_v: Value) -> Value {
|
|
match arg_t.as_str() {
|
|
"timestamp" | "datetime" | "date" | "time" => {
|
|
let mut v: String = arg_v.as_str().unwrap_or("").to_owned();
|
|
|
|
match arg_t.as_str() {
|
|
"timestamp" | "datetime" => {
|
|
v = v.trim_end_matches("Z").to_string();
|
|
}
|
|
"date" => {
|
|
let arr = v.split("T").collect::<Vec<&str>>();
|
|
match arr.as_slice() {
|
|
[date, _] => {
|
|
v = date.to_string();
|
|
}
|
|
_ => {}
|
|
}
|
|
}
|
|
"time" => {
|
|
let arr = v.split("T").collect::<Vec<&str>>();
|
|
match arr.as_slice() {
|
|
[_, time] => {
|
|
v = time.trim_end_matches("Z").to_string();
|
|
}
|
|
_ => {}
|
|
}
|
|
}
|
|
_ => {}
|
|
}
|
|
|
|
json!({ "value": json!(v) })
|
|
}
|
|
_ => {
|
|
let mut v = arg_v;
|
|
|
|
if !v.is_string() {
|
|
// if not string, convert to string for api request
|
|
v = json!(v.to_string());
|
|
}
|
|
|
|
json!({
|
|
"value": v,
|
|
}
|
|
)
|
|
}
|
|
}
|
|
}
|
|
|
|
fn parse_val(value: &Value, typ: &str, schema: &BigqueryResponseSchemaField) -> Value {
|
|
let str_value = value.as_str().unwrap_or("").to_string();
|
|
|
|
if value.is_array() {
|
|
return Value::Array(
|
|
value
|
|
.as_array()
|
|
.unwrap_or(&vec![])
|
|
.iter()
|
|
.map(|x| {
|
|
parse_val(
|
|
&serde_json::from_value::<BigqueryResponseValue>(x.clone())
|
|
.ok()
|
|
.unwrap_or(BigqueryResponseValue { v: json!({}) })
|
|
.v,
|
|
typ,
|
|
schema,
|
|
)
|
|
})
|
|
.collect::<Vec<Value>>(),
|
|
);
|
|
}
|
|
match typ.to_lowercase().as_str() {
|
|
"struct" | "record" => {
|
|
let mut nested_row_map = serde_json::Map::new();
|
|
serde_json::from_value::<BigqueryResponseRow>(value.clone())
|
|
.ok()
|
|
.unwrap_or(BigqueryResponseRow { f: vec![] })
|
|
.f
|
|
.iter()
|
|
.zip(schema.fields.as_ref().clone().unwrap_or(&vec![]).iter())
|
|
.for_each(|(f, s)| {
|
|
nested_row_map.insert(s.name.clone(), parse_val(&f.v, &s.r#type, &s));
|
|
});
|
|
Value::from(nested_row_map)
|
|
}
|
|
"bool" | "boolean" => json!(str_value.parse::<bool>().ok().unwrap_or(false)),
|
|
"float" | "float64" => json!(str_value.parse::<f64>().ok().unwrap_or(0.0)),
|
|
"int64" | "integer" | "timestamp" => json!(str_value.parse::<i64>().ok().unwrap_or(0)),
|
|
"json" => serde_json::from_str(&str_value).ok().unwrap_or(json!({})),
|
|
_ => value.clone(),
|
|
}
|
|
}
|