fix: improve jobs runs page performance (#6942)

This commit is contained in:
Ruben Fiszel
2025-10-27 15:29:53 +01:00
committed by GitHub
parent 248bb24606
commit 5b315bf2c8
27 changed files with 425 additions and 264 deletions
@@ -1,12 +0,0 @@
{
"db_name": "PostgreSQL",
"query": "create index concurrently if not exists ix_job_created_at ON v2_job (created_at DESC)",
"describe": {
"columns": [],
"parameters": {
"Left": []
},
"nullable": []
},
"hash": "3a5edf3dd884b5a8862bb112f6520967ed4a218782192c6c6fc1498f45d753a6"
}
@@ -0,0 +1,12 @@
{
"db_name": "PostgreSQL",
"query": "DROP INDEX CONCURRENTLY IF EXISTS ix_completed_job_workspace_id_started_at_new_2",
"describe": {
"columns": [],
"parameters": {
"Left": []
},
"nullable": []
},
"hash": "3dca0aded0ec744b084359e1a77dc4af312fda13d832f9e8b236655628e5b81c"
}
@@ -15,7 +15,7 @@
]
},
"nullable": [
true
null
]
},
"hash": "5a219a2532517869578c4504ff3153c43903f929ae5d62fbba12610f89c36d55"
@@ -0,0 +1,12 @@
{
"db_name": "PostgreSQL",
"query": "create index concurrently if not exists ix_job_workspace_id_completed_at_all ON v2_job_completed (workspace_id, completed_at DESC)",
"describe": {
"columns": [],
"parameters": {
"Left": []
},
"nullable": []
},
"hash": "5c403799772e1c44bd00e5376893a6428b3c7cbdef4af70a6660c1a5900d17a6"
}
@@ -1,6 +1,6 @@
{
"db_name": "PostgreSQL",
"query": "create index concurrently if not exists ix_v2_job_workspace_id_created_at ON v2_job (workspace_id, created_at DESC) where kind in ('script', 'flow', 'singlescriptflow') AND parent_job IS NULL",
"query": "create index concurrently if not exists ix_v2_job_workspace_id_created_at ON v2_job (workspace_id, created_at DESC) where kind in ('script', 'flow', 'singlestepflow') AND parent_job IS NULL",
"describe": {
"columns": [],
"parameters": {
@@ -8,5 +8,5 @@
},
"nullable": []
},
"hash": "d585aa6301c41308b02a1f0fbf068221e732e48dfa6e34d5b025adbbdcbb03e0"
"hash": "5d99d2b058d4896f9ac1cea04fa35c003bc4e897ee01746d6f17aeee387d1505"
}
@@ -1,6 +1,6 @@
{
"db_name": "PostgreSQL",
"query": "SELECT\n schedule.path, t.jobs FROM schedule,\n LATERAL(SELECT ARRAY(\n SELECT json_build_object('id', id, 'success', status = 'success', 'duration_ms', duration_ms)\n FROM v2_job_completed c JOIN v2_job j USING (id)\n WHERE trigger_kind = 'schedule'\n AND trigger = schedule.path\n AND c.workspace_id = $1\n AND j.workspace_id = $1\n AND parent_job IS NULL AND runnable_path = schedule.script_path\n AND status <> 'skipped'\n ORDER BY created_at DESC\n LIMIT 20\n ) AS jobs) t\n WHERE workspace_id = $1\n ORDER BY edited_at DESC\n LIMIT $2 OFFSET $3",
"query": "SELECT\n schedule.path, t.jobs FROM schedule,\n LATERAL(SELECT ARRAY(\n SELECT json_build_object('id', id, 'success', status = 'success', 'duration_ms', duration_ms)\n FROM v2_job_completed c JOIN v2_job j USING (id)\n WHERE trigger_kind = 'schedule'\n AND trigger = schedule.path\n AND c.workspace_id = $1\n AND j.workspace_id = $1\n AND parent_job IS NULL AND runnable_path = schedule.script_path\n AND status <> 'skipped'\n ORDER BY completed_at DESC\n LIMIT 20\n ) AS jobs) t\n WHERE workspace_id = $1\n ORDER BY edited_at DESC\n LIMIT $2 OFFSET $3",
"describe": {
"columns": [
{
@@ -26,5 +26,5 @@
null
]
},
"hash": "430ea56dea72c7d326735952bf85708dcc4811857b7e5631f0decf5d75ef3aa3"
"hash": "5e6dcb3e7a9bc174a040cfba96555eb5140c563a2b191714ef9028b56391b794"
}
@@ -1,16 +0,0 @@
{
"db_name": "PostgreSQL",
"query": "\n INSERT INTO script\n (workspace_id, hash, path, parent_hashes, summary, description, content, created_by, schema, is_template, extra_perms, lock, language, kind, tag, draft_only, envs, concurrent_limit, concurrency_time_window_s, cache_ttl, dedicated_worker, ws_error_handler_muted, priority, restart_unless_cancelled, delete_after_use, timeout, concurrency_key, visible_to_runner_only, no_main_func, codebase, has_preprocessor, on_behalf_of_email, schema_validation, assets)\n\n SELECT workspace_id, $1, path, array_prepend($2::bigint, COALESCE(parent_hashes, '{}'::bigint[])), summary, description, content, created_by, schema, is_template, extra_perms, NULL, language, kind, tag, draft_only, envs, concurrent_limit, concurrency_time_window_s, cache_ttl, dedicated_worker, ws_error_handler_muted, priority, restart_unless_cancelled, delete_after_use, timeout, concurrency_key, visible_to_runner_only, no_main_func, codebase, has_preprocessor, on_behalf_of_email, schema_validation, assets\n\n FROM script WHERE hash = $2 AND workspace_id = $3;\n ",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Int8",
"Int8",
"Text"
]
},
"nullable": []
},
"hash": "69550451b86f221a3d2ef626be7073fc77421710992b10f526aa36bc64ff0930"
}
@@ -0,0 +1,12 @@
{
"db_name": "PostgreSQL",
"query": "DROP INDEX CONCURRENTLY IF EXISTS ix_job_created_at",
"describe": {
"columns": [],
"parameters": {
"Left": []
},
"nullable": []
},
"hash": "6ad24aef02d86ea507d232ad0bbf798240d4fcdd52ce2745532ca30f736d25ca"
}
@@ -1,12 +0,0 @@
{
"db_name": "PostgreSQL",
"query": "create index concurrently if not exists ix_completed_job_workspace_id_started_at_new_2 ON v2_job_completed (workspace_id, started_at DESC)",
"describe": {
"columns": [],
"parameters": {
"Left": []
},
"nullable": []
},
"hash": "931703a98d2ee5fb58d3380896baaee032e731db1e6bd49d991a54f49ab8fa46"
}
@@ -0,0 +1,12 @@
{
"db_name": "PostgreSQL",
"query": "DROP INDEX CONCURRENTLY IF EXISTS ix_v2_job_root_by_path",
"describe": {
"columns": [],
"parameters": {
"Left": []
},
"nullable": []
},
"hash": "ea9385509319f66b9330221eb50b85edafb9408d0306f17bb78b65a5d81c570b"
}
@@ -0,0 +1,12 @@
{
"db_name": "PostgreSQL",
"query": "CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_job_v2_job_root_by_path_2 ON v2_job (workspace_id, runnable_path) WHERE parent_job IS NULL;",
"describe": {
"columns": [],
"parameters": {
"Left": []
},
"nullable": []
},
"hash": "f17c9fed09897191ee70214223d4b83a05f1d649bfedff9f24cc3e6c702d42df"
}
+60 -49
View File
@@ -8058,11 +8058,12 @@ paths:
- $ref: "#/components/parameters/StartedAfter"
- $ref: "#/components/parameters/CreatedBefore"
- $ref: "#/components/parameters/CreatedAfter"
- $ref: "#/components/parameters/CreatedOrStartedBefore"
- $ref: "#/components/parameters/CompletedBefore"
- $ref: "#/components/parameters/CompletedAfter"
- $ref: "#/components/parameters/CreatedBeforeQueue"
- $ref: "#/components/parameters/CreatedAfterQueue"
- $ref: "#/components/parameters/Running"
- $ref: "#/components/parameters/ScheduledForBeforeNow"
- $ref: "#/components/parameters/CreatedOrStartedAfter"
- $ref: "#/components/parameters/CreatedOrStartedAfterCompletedJob"
- $ref: "#/components/parameters/JobKinds"
- $ref: "#/components/parameters/Suspended"
- $ref: "#/components/parameters/ArgsFilter"
@@ -8272,19 +8273,18 @@ paths:
- $ref: "#/components/parameters/StartedAfter"
- $ref: "#/components/parameters/CreatedBefore"
- $ref: "#/components/parameters/CreatedAfter"
- $ref: "#/components/parameters/CreatedOrStartedBefore"
- $ref: "#/components/parameters/CompletedBefore"
- $ref: "#/components/parameters/CompletedAfter"
- $ref: "#/components/parameters/CreatedBeforeQueue"
- $ref: "#/components/parameters/CreatedAfterQueue"
- $ref: "#/components/parameters/Running"
- $ref: "#/components/parameters/ScheduledForBeforeNow"
- $ref: "#/components/parameters/CreatedOrStartedAfter"
- $ref: "#/components/parameters/CreatedOrStartedAfterCompletedJob"
- $ref: "#/components/parameters/JobKinds"
- $ref: "#/components/parameters/Suspended"
- $ref: "#/components/parameters/ArgsFilter"
- $ref: "#/components/parameters/Tag"
- $ref: "#/components/parameters/ResultFilter"
- $ref: "#/components/parameters/AllowWildcards"
- $ref: "#/components/parameters/Page"
- $ref: "#/components/parameters/PerPage"
- name: is_skipped
description: is the job skipped
in: query
@@ -14245,11 +14245,12 @@ paths:
- $ref: "#/components/parameters/ScriptExactHash"
- $ref: "#/components/parameters/StartedBefore"
- $ref: "#/components/parameters/StartedAfter"
- $ref: "#/components/parameters/CreatedOrStartedBefore"
- $ref: "#/components/parameters/Running"
- $ref: "#/components/parameters/ScheduledForBeforeNow"
- $ref: "#/components/parameters/CreatedOrStartedAfter"
- $ref: "#/components/parameters/CreatedOrStartedAfterCompletedJob"
- $ref: "#/components/parameters/CompletedBefore"
- $ref: "#/components/parameters/CompletedAfter"
- $ref: "#/components/parameters/CreatedBeforeQueue"
- $ref: "#/components/parameters/CreatedAfterQueue"
- $ref: "#/components/parameters/JobKinds"
- $ref: "#/components/parameters/ArgsFilter"
- $ref: "#/components/parameters/Tag"
@@ -14838,33 +14839,40 @@ components:
schema:
type: string
format: date-time
CreatedOrStartedAfter:
name: created_or_started_after
description:
filter on created_at for non non started job and started_at otherwise
after (exclusive) timestamp
CompletedBefore:
name: completed_before
description: filter on started before (inclusive) timestamp
in: query
schema:
type: string
format: date-time
CreatedOrStartedAfterCompletedJob:
name: created_or_started_after_completed_jobs
description:
filter on created_at for non non started job and started_at otherwise
after (exclusive) timestamp but only for the completed jobs
CompletedAfter:
name: completed_after
description: filter on started after (exclusive) timestamp
in: query
schema:
type: string
format: date-time
CreatedOrStartedBefore:
name: created_or_started_before
CreatedAfterQueue:
name: created_after_queue
description:
filter on created_at for non non started job and started_at otherwise
before (inclusive) timestamp
filter on jobs created after X for jobs in the queue only
in: query
schema:
type: string
format: date-time
CreatedBeforeQueue:
name: created_before_queue
description:
filter on jobs created before X for jobs in the queue only
in: query
schema:
type: string
format: date-time
Success:
name: success
description: filter on successful jobs
@@ -15669,6 +15677,9 @@ components:
started_at:
type: string
format: date-time
completed_at:
type: string
format: date-time
duration_ms:
type: integer
success:
@@ -16302,30 +16313,30 @@ components:
ScriptLang:
type: string
enum: [
python3,
deno,
go,
bash,
powershell,
postgresql,
mysql,
bigquery,
snowflake,
mssql,
oracledb,
graphql,
nativets,
bun,
php,
rust,
ansible,
csharp,
nu,
java,
ruby,
duckdb,
# for related places search: ADD_NEW_LANG
]
python3,
deno,
go,
bash,
powershell,
postgresql,
mysql,
bigquery,
snowflake,
mssql,
oracledb,
graphql,
nativets,
bun,
php,
rust,
ansible,
csharp,
nu,
java,
ruby,
duckdb,
# for related places search: ADD_NEW_LANG
]
Preview:
type: object
@@ -166,7 +166,7 @@ async fn get_concurrent_intervals(
.clone();
let mut sqlb_c = SqlBuilder::select_from("v2_job_completed")
.fields(UnifiedJob::completed_job_fields())
.order_by("started_at", lq.order_desc.unwrap_or(true))
.order_by("completed_at", lq.order_desc.unwrap_or(true))
.limit(row_limit)
.clone();
let mut sqlb_q_user = SqlBuilder::select_from("v2_job_queue")
@@ -176,7 +176,7 @@ async fn get_concurrent_intervals(
.clone();
let mut sqlb_c_user = SqlBuilder::select_from("v2_job_completed")
.fields(&["id"])
.order_by("started_at", lq.order_desc.unwrap_or(true))
.order_by("completed_at", lq.order_desc.unwrap_or(true))
.limit(row_limit)
.clone();
@@ -209,6 +209,10 @@ async fn get_concurrent_intervals(
started_after: _,
created_before: _,
created_after: _,
created_before_queue: _,
created_after_queue: _,
completed_after: _,
completed_before: _,
created_or_started_before: _,
created_or_started_after: _,
created_or_started_after_completed_jobs: _,
@@ -296,6 +300,7 @@ async fn get_concurrent_intervals(
duration_ms: j.duration_ms,
})
.collect();
let jobs = running_jobs_db
.into_iter()
.filter(|j| running_jobs_user.iter().any(|id| j.id == *id))
@@ -306,6 +311,7 @@ async fn get_concurrent_intervals(
)
.map(From::from)
.collect();
Ok(Json(ExtendedJobs {
jobs,
obscured_jobs,
+48 -29
View File
@@ -778,7 +778,7 @@ macro_rules! get_job_query {
("v2_job_completed", $($opts:tt)*) => {
get_job_query!(
@impl "v2_job_completed", ($($opts)*),
"v2_job_completed.duration_ms, CASE WHEN status = 'success' OR status = 'skipped' THEN true ELSE false END as success, result_columns, deleted, status = 'skipped' as is_skipped, result->'wm_labels' as labels, \
"v2_job_completed.duration_ms, v2_job_completed.completed_at, CASE WHEN status = 'success' OR status = 'skipped' THEN true ELSE false END as success, result_columns, deleted, status = 'skipped' as is_skipped, result->'wm_labels' as labels, \
CASE WHEN result is null or pg_column_size(result) < 90000 THEN result ELSE '\"WINDMILL_TOO_BIG\"'::jsonb END as result",
"",
)
@@ -1820,8 +1820,8 @@ impl From<ListCompletedQuery> for ListQueueQuery {
created_by: lcq.created_by,
started_before: lcq.started_before,
started_after: lcq.started_after,
created_before: lcq.created_before,
created_after: lcq.created_after,
created_before: lcq.created_before_queue.or(lcq.created_before),
created_after: lcq.created_after_queue.or(lcq.created_after),
created_or_started_before: lcq.created_or_started_before,
created_or_started_after: lcq.created_or_started_after,
worker: lcq.worker,
@@ -1855,11 +1855,11 @@ pub fn filter_list_queue_query(
if join_outstanding_wait_times {
sqlb.left()
.join("outstanding_wait_time")
.on_eq("v2_job.id", "outstanding_wait_time.job_id");
.on_eq("v2_job_queue.id", "outstanding_wait_time.job_id");
}
if w_id != "admins" || !lq.all_workspaces.is_some_and(|x| x) {
sqlb.and_where_eq("v2_job.workspace_id", "?".bind(&w_id));
sqlb.and_where_eq("v2_job_queue.workspace_id", "?".bind(&w_id));
}
if let Some(w) = &lq.worker {
@@ -1960,8 +1960,7 @@ pub fn filter_list_queue_query(
}
if lq.is_not_schedule.unwrap_or(false) {
sqlb.and_where("trigger_kind != 'schedule'")
.or_where("trigger_kind IS NULL");
sqlb.and_where("trigger_kind IS DISTINCT FROM 'schedule'");
}
sqlb
@@ -2360,10 +2359,13 @@ async fn list_jobs(
Query(pagination): Query<Pagination>,
Query(lq): Query<ListCompletedQuery>,
) -> error::JsonResult<Vec<Job>> {
let limit = pagination.per_page.unwrap_or(1000);
let (per_page, offset) = paginate(pagination);
let lqc = lq.clone();
if offset > 0 {
tracing::warn!("offset is not 0, but is ignored for list_jobs. Use created_before or completed_before instead.");
}
if lq.success.is_some() && lq.running.is_some_and(|x| x) {
return Err(error::Error::BadRequest(
"cannot specify both success and running".to_string(),
@@ -2372,7 +2374,7 @@ async fn list_jobs(
let sqlc = if lq.running.is_none() {
Some(list_completed_jobs_query(
&w_id,
Some(per_page + offset),
Some(per_page),
0,
&ListCompletedQuery { order_desc: Some(true), ..lqc },
UnifiedJob::completed_job_fields(),
@@ -2392,19 +2394,13 @@ async fn list_jobs(
&w_id,
&ListQueueQuery { order_desc: Some(true), ..lq.into() },
UnifiedJob::queued_job_fields(),
Pagination { per_page: Some(limit), page: None },
Pagination { per_page: None, page: None },
true,
get_scope_tags(&authed),
);
if let Some(sqlc) = sqlc {
format!(
"{} UNION ALL {} LIMIT {} OFFSET {};",
&sqlq.subquery()?,
&sqlc.subquery()?,
per_page,
offset
)
format!("{} UNION ALL {}", &sqlq.subquery()?, &sqlc.subquery()?,)
} else {
sqlq.limit(per_page).offset(offset).query()?
}
@@ -2418,6 +2414,7 @@ async fn list_jobs(
}
sqlc.unwrap().limit(per_page).offset(offset).query()?
};
// tracing::info!("sql: {}", sql);
let mut tx: Transaction<'_, Postgres> = user_db.begin(&authed).await?;
let jobs: Vec<UnifiedJob> = sqlx::query_as(&sql)
@@ -3295,6 +3292,7 @@ pub struct UnifiedJob {
pub created_by: String,
pub created_at: chrono::DateTime<chrono::Utc>,
pub started_at: Option<chrono::DateTime<chrono::Utc>>,
pub completed_at: Option<chrono::DateTime<chrono::Utc>>,
pub scheduled_for: Option<chrono::DateTime<chrono::Utc>>,
pub running: Option<bool>,
pub script_hash: Option<ScriptHash>,
@@ -3327,13 +3325,14 @@ pub struct UnifiedJob {
const CJ_FIELDS: &[&str] = &[
"'CompletedJob' as typ",
"v2_job.id",
"v2_job.workspace_id",
"v2_job_completed.id",
"v2_job_completed.workspace_id",
"v2_job.parent_job",
"v2_job.created_by",
"v2_job.created_at",
"v2_job_completed.started_at",
"null as scheduled_for",
"v2_job_completed.completed_at",
"null as running",
"v2_job.runnable_id as script_hash",
"v2_job.runnable_path as script_path",
@@ -3366,13 +3365,14 @@ const CJ_FIELDS: &[&str] = &[
const QJ_FIELDS: &[&str] = &[
"'QueuedJob' as typ",
"v2_job.id",
"v2_job.workspace_id",
"v2_job_queue.id",
"v2_job_queue.workspace_id",
"v2_job.parent_job",
"v2_job.created_by",
"v2_job.created_at",
"v2_job_queue.created_at",
"v2_job_queue.started_at",
"v2_job_queue.scheduled_for",
"null as completed_at",
"v2_job_queue.running",
"v2_job.runnable_id as script_hash",
"v2_job.runnable_path as script_path",
@@ -3425,6 +3425,7 @@ impl<'a> From<UnifiedJob> for Job {
created_by: uj.created_by,
created_at: uj.created_at,
started_at: uj.started_at,
completed_at: uj.completed_at,
duration_ms: uj.duration_ms.unwrap(),
success: uj.success.unwrap(),
script_hash: uj.script_hash,
@@ -7507,7 +7508,7 @@ pub fn filter_list_completed_query(
if join_outstanding_wait_times {
sqlb.left()
.join("outstanding_wait_time")
.on_eq("v2_job.id", "outstanding_wait_time.job_id");
.on_eq("v2_job_completed.id", "outstanding_wait_time.job_id");
}
if let Some(label) = &lq.label {
@@ -7535,7 +7536,8 @@ pub fn filter_list_completed_query(
}
if w_id != "admins" || !lq.all_workspaces.is_some_and(|x| x) {
sqlb.and_where_eq("v2_job.workspace_id", "?".bind(&w_id));
sqlb.and_where_eq("v2_job_completed.workspace_id", "?".bind(&w_id))
.and_where_eq("v2_job.workspace_id", "?".bind(&w_id));
}
if let Some(p) = &lq.schedule_path {
@@ -7605,6 +7607,13 @@ pub fn filter_list_completed_query(
sqlb.and_where_ge("started_at", "?".bind(&dt.to_rfc3339()));
}
if let Some(dt) = &lq.completed_after {
sqlb.and_where_ge("completed_at", "?".bind(&dt.to_rfc3339()));
}
if let Some(dt) = &lq.completed_before {
sqlb.and_where_le("completed_at", "?".bind(&dt.to_rfc3339()));
}
if let Some(sk) = &lq.is_skipped {
if *sk {
sqlb.and_where_eq("status", "'skipped'");
@@ -7640,8 +7649,7 @@ pub fn filter_list_completed_query(
}
if lq.is_not_schedule.unwrap_or(false) {
sqlb.and_where("trigger_kind != 'schedule'")
.or_where("trigger_kind IS NULL");
sqlb.and_where("trigger_kind IS DISTINCT FROM 'schedule'");
}
sqlb
@@ -7658,7 +7666,14 @@ pub fn list_completed_jobs_query(
) -> SqlBuilder {
let mut sqlb = SqlBuilder::select_from("v2_job_completed")
.fields(fields)
.order_by("v2_job.created_at", lq.order_desc.unwrap_or(true))
.order_by(
if lq.completed_before.is_some() || lq.completed_after.is_some() {
"v2_job_completed.completed_at"
} else {
"v2_job.created_at"
},
lq.order_desc.unwrap_or(true),
)
.offset(offset)
.clone();
if let Some(per_page) = per_page {
@@ -7687,6 +7702,10 @@ pub struct ListCompletedQuery {
pub created_or_started_before: Option<chrono::DateTime<chrono::Utc>>,
pub created_or_started_after: Option<chrono::DateTime<chrono::Utc>>,
pub created_or_started_after_completed_jobs: Option<chrono::DateTime<chrono::Utc>>,
pub created_before_queue: Option<chrono::DateTime<chrono::Utc>>,
pub created_after_queue: Option<chrono::DateTime<chrono::Utc>>,
pub completed_after: Option<chrono::DateTime<chrono::Utc>>,
pub completed_before: Option<chrono::DateTime<chrono::Utc>>,
pub success: Option<bool>,
pub running: Option<bool>,
pub parent_job: Option<String>,
@@ -7726,8 +7745,8 @@ async fn list_completed_jobs(
offset,
&lq,
&[
"v2_job.id",
"v2_job.workspace_id",
"v2_job_completed.id",
"v2_job_completed.workspace_id",
"v2_job.parent_job",
"v2_job.created_by",
"v2_job.created_at",
+50 -23
View File
@@ -447,19 +447,6 @@ async fn fix_job_completed_index(db: &DB) -> Result<(), Error> {
i += 1;
tracing::info!("step {i} of {migration_job_name} migration");
sqlx::query!("create index concurrently if not exists ix_completed_job_workspace_id_started_at_new_2 ON v2_job_completed (workspace_id, started_at DESC)")
.execute(db)
.await?;
i += 1;
tracing::info!("step {i} of {migration_job_name} migration");
sqlx::query!("create index concurrently if not exists ix_job_root_job_index_by_path_2 ON v2_job (workspace_id, runnable_path, created_at desc) WHERE parent_job IS NULL")
.execute(db)
.await?;
i += 1;
tracing::info!("step {i} of {migration_job_name} migration");
sqlx::query!("DROP INDEX CONCURRENTLY IF EXISTS root_job_index_by_path_2")
.execute(db)
.await?;
@@ -467,13 +454,6 @@ async fn fix_job_completed_index(db: &DB) -> Result<(), Error> {
i += 1;
tracing::info!("step {i} of {migration_job_name} migration");
sqlx::query!("create index concurrently if not exists ix_job_created_at ON v2_job (created_at DESC)")
.execute(db)
.await?;
i += 1;
tracing::info!("step {i} of {migration_job_name} migration");
sqlx::query(
"DROP INDEX CONCURRENTLY IF EXISTS ix_completed_job_workspace_id_created_at_new_2",
)
@@ -523,9 +503,9 @@ async fn fix_job_completed_index(db: &DB) -> Result<(), Error> {
});
run_windmill_migration!("v2_improve_v2_job_indices_ii", &db, |tx| {
sqlx::query!("create index concurrently if not exists ix_v2_job_workspace_id_created_at ON v2_job (workspace_id, created_at DESC) where kind in ('script', 'flow', 'singlescriptflow') AND parent_job IS NULL")
.execute(db)
.await?;
sqlx::query!("create index concurrently if not exists ix_v2_job_workspace_id_created_at ON v2_job (workspace_id, created_at DESC) where kind in ('script', 'flow', 'singlestepflow') AND parent_job IS NULL")
.execute(db)
.await?;
sqlx::query!("DROP INDEX CONCURRENTLY IF EXISTS ix_job_workspace_id_created_at_new_6")
.execute(db)
@@ -609,5 +589,52 @@ WHERE operation IN ('users.login', 'oauth.login', 'users.token.refresh');"
.execute(db)
.await?;
});
run_windmill_migration!("v2_job_completed_completed_at_9", db, |tx| {
let migration_job_name = "v2_job_completed_completed_at";
let mut i = 1;
tracing::info!("step {i} of {migration_job_name} migration");
sqlx::query!("create index concurrently if not exists ix_job_workspace_id_completed_at_all ON v2_job_completed (workspace_id, completed_at DESC)")
.execute(db)
.await?;
i += 1;
sqlx::query!("CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_job_v2_job_root_by_path_2 ON v2_job (workspace_id, runnable_path) WHERE parent_job IS NULL;")
.execute(db)
.await?;
i += 1;
tracing::info!("step {i} of {migration_job_name} migration");
sqlx::query!("create index concurrently if not exists ix_job_root_job_index_by_path_2 ON v2_job (workspace_id, runnable_path, created_at desc) WHERE parent_job IS NULL")
.execute(db)
.await?;
i += 1;
tracing::info!("step {i} of {migration_job_name} migration");
sqlx::query!(
"DROP INDEX CONCURRENTLY IF EXISTS ix_completed_job_workspace_id_started_at_new_2"
)
.execute(db)
.await?;
i += 1;
tracing::info!("step {i} of {migration_job_name} migration");
sqlx::query!("DROP INDEX CONCURRENTLY IF EXISTS ix_job_created_at")
.execute(db)
.await?;
i += 1;
sqlx::query!("DROP INDEX CONCURRENTLY IF EXISTS ix_v2_job_root_by_path")
.execute(db)
.await?;
i += 1;
tracing::info!("step {i} of {migration_job_name} migration");
});
Ok(())
}
+1 -1
View File
@@ -583,7 +583,7 @@ async fn list_schedule_with_jobs(
AND j.workspace_id = $1
AND parent_job IS NULL AND runnable_path = schedule.script_path
AND status <> 'skipped'
ORDER BY created_at DESC
ORDER BY completed_at DESC
LIMIT 20
) AS jobs) t
WHERE workspace_id = $1
+1
View File
@@ -263,6 +263,7 @@ pub struct CompletedJob {
pub created_by: String,
pub created_at: chrono::DateTime<chrono::Utc>,
pub started_at: Option<chrono::DateTime<chrono::Utc>>,
pub completed_at: Option<chrono::DateTime<chrono::Utc>>,
pub duration_ms: i64,
pub success: bool,
#[serde(skip_serializing_if = "Option::is_none")]
+1 -2
View File
@@ -2427,8 +2427,7 @@ pub async fn pull(
}
#[cfg(not(feature = "enterprise"))]
let has_concurent_limit = false
|| (job.is_dependency() && job.concurrent_limit.is_some() && cfg!(feature = "private") && !*WMDEBUG_NO_DJOB_DEBOUNCING);
let has_concurent_limit = job.is_dependency() && job.concurrent_limit.is_some() && cfg!(feature = "private") && !*WMDEBUG_NO_DJOB_DEBOUNCING;
// if we don't have private flag, we don't have concurrency limit
// concurrency check. If more than X jobs for this path are already running, we re-queue and pull another job from the queue
@@ -1,15 +1,24 @@
<script lang="ts">
import { HistoryIcon } from 'lucide-svelte'
import { createEventDispatcher } from 'svelte'
import { createEventDispatcher, untrack } from 'svelte'
import PopoverV2 from '$lib/components/meltComponents/Popover.svelte'
import HistoricInputs from './HistoricInputs.svelte'
import { workspaceStore } from '$lib/stores'
import { JobService } from '$lib/gen'
export let path: string
export let selected: string | undefined = undefined
export let selectInitial: boolean = false
export let loading: boolean = false
interface Props {
path: string;
selected?: string | undefined;
selectInitial?: boolean;
loading?: boolean;
}
let {
path,
selected = undefined,
selectInitial = false,
loading = $bindable(false)
}: Props = $props();
const dispatch = createEventDispatcher()
async function loadInitial() {
@@ -31,27 +40,33 @@
loading = false
}
$: $workspaceStore && loadInitial()
$effect(() => {
$workspaceStore && untrack(() => loadInitial())
});
</script>
<PopoverV2 closeButton={false}>
<svelte:fragment slot="trigger">
<HistoryIcon size={14} />
</svelte:fragment>
<svelte:fragment slot="content">
<div class="p-2 h-[400px] overflow-hidden w-80 border shadow-sm">
<HistoricInputs
on:select={(e) => {
if (e.detail) {
dispatch('select', { jobId: e.detail?.jobId, initial: false })
} else {
dispatch('unselect')
}
}}
{selected}
runnableId={path}
runnableType={'FlowPath'}
/>
</div>
</svelte:fragment>
{#snippet trigger()}
<HistoryIcon size={14} />
{/snippet}
{#snippet content()}
<div class="p-2 h-[400px] overflow-hidden w-80 border shadow-sm">
<HistoricInputs
on:select={(e) => {
if (e.detail) {
dispatch('select', { jobId: e.detail?.jobId, initial: false })
} else {
dispatch('unselect')
}
}}
{selected}
runnableId={path}
runnableType={'FlowPath'}
/>
</div>
{/snippet}
</PopoverV2>
+6 -6
View File
@@ -137,10 +137,10 @@
}
function minJobTime(jobs: CompletedJob[]): Date {
let min: Date = new Date(jobs[0].started_at)
let min: Date = new Date(jobs[0].completed_at!)
for (const job of jobs) {
if (job.started_at != undefined) {
const date = new Date(job.started_at)
if (job.completed_at != undefined) {
const date = new Date(job.completed_at)
if (date < min) {
min = date
}
@@ -150,10 +150,10 @@
}
function maxJobTime(jobs: CompletedJob[]): Date {
let max: Date = new Date(jobs[0].started_at)
let max: Date = new Date(jobs[0].completed_at!)
for (const job of jobs) {
if (new Date(job.started_at) > max) {
max = new Date(job.started_at)
if (new Date(job.completed_at!) > max) {
max = new Date(job.completed_at!)
}
}
return max
+44 -29
View File
@@ -1,4 +1,5 @@
<script lang="ts">
import { Button } from '$lib/components/common'
import {
InputService,
@@ -10,7 +11,7 @@
import { userStore, workspaceStore } from '$lib/stores.js'
import { base } from '$lib/base'
import { classNames, displayDateOnly, sendUserToast } from '$lib/utils.js'
import { createEventDispatcher } from 'svelte'
import { createEventDispatcher, untrack } from 'svelte'
import { Pane, Splitpanes } from 'svelte-splitpanes'
import ObjectViewer from './propertyPicker/ObjectViewer.svelte'
import { ArrowLeftIcon, Edit, ExternalLink, X } from 'lucide-svelte'
@@ -21,37 +22,49 @@
import Skeleton from './common/skeleton/Skeleton.svelte'
import SaveInputsButton from './SaveInputsButton.svelte'
export let scriptHash: string | null = null
export let scriptPath: string | null = null
export let flowPath: string | null = null
export let canSaveInputs: boolean = true
// Are the current Inputs valid and able to be saved?
export let isValid: boolean
export let args: object
interface Props {
scriptHash?: string | null;
scriptPath?: string | null;
flowPath?: string | null;
canSaveInputs?: boolean;
// Are the current Inputs valid and able to be saved?
isValid: boolean;
args: object;
}
let {
scriptHash = null,
scriptPath = null,
flowPath = null,
canSaveInputs = true,
isValid,
args
}: Props = $props();
interface EditableInput extends Input {
isEditing?: boolean
isSaving?: boolean
}
let previousInputs: Input[] | undefined = undefined
let savedInputs: EditableInput[] | undefined = undefined
let selectedInput: Input | null
let jobs: Job[] = []
let loading: boolean = false
let previousInputs: Input[] | undefined = $state(undefined)
let savedInputs: EditableInput[] | undefined = $state(undefined)
let selectedInput = $state() as Input | null
let jobs: Job[] = $state([])
let loading: boolean = $state(false)
const dispatch = createEventDispatcher()
$: runnableId = scriptHash || scriptPath || flowPath || undefined
let runnableId = $derived(scriptHash || scriptPath || flowPath || undefined)
let runnableType: RunnableType | undefined = $derived(scriptHash
? 'ScriptHash'
: scriptPath
? 'ScriptPath'
: flowPath
? 'FlowPath'
: undefined)
let runnableType: RunnableType | undefined = undefined
$: runnableType = scriptHash
? 'ScriptHash'
: scriptPath
? 'ScriptPath'
: flowPath
? 'FlowPath'
: undefined
let hasAlreadyFailed = false
async function loadInputHistory() {
@@ -115,14 +128,16 @@
}
}
$: {
$effect(() => {
if ($workspaceStore && (scriptHash || scriptPath || flowPath)) {
untrack(() => {
loadInputHistory()
loadSavedInputs()
})
}
}
});
let previewArgs: any = undefined
let previewArgs: any = $state(undefined)
function selectArgs(selected_args: any) {
previewArgs = selected_args
@@ -197,7 +212,7 @@
`w-full flex items-center text-sm group justify-between gap-4 py-1.5 px-4 text-left border rounded-sm hover:bg-surface-hover transition-all`,
selectedInput === i ? 'border-blue-500 bg-blue-50 dark:bg-blue-900' : ''
)}
on:click={async () => {
onclick={async () => {
if (!i.isEditing) {
if (selectedInput === i) {
selectedInput = null
@@ -211,7 +226,7 @@
<div class="w-full h-full items-center justify-between flex gap-1 min-w-0">
{#if i.isEditing}
<form
on:submit={() => {
onsubmit={() => {
updateInput(i)
i.isEditing = false
i.isSaving = false
@@ -296,7 +311,7 @@
`w-full flex items-center justify-between gap-4 py-2 px-4 text-left border rounded-sm hover:bg-surface-hover transition-a`,
'border-orange-400'
)}
on:click={async () => {
onclick={async () => {
if (!$workspaceStore) {
return
}
@@ -364,7 +379,7 @@
`w-full flex items-center justify-between gap-4 py-2 px-4 text-left border rounded-sm hover:bg-surface-hover transition-a`,
selectedInput === i ? 'border-blue-500 bg-blue-50 dark:bg-blue-900' : ''
)}
on:click={async () => {
onclick={async () => {
if (selectedInput === i) {
selectedInput = null
} else {
@@ -327,7 +327,7 @@
loadingJobs: true
}
}
const previousJobId = await JobService.listJobs({
const previousJobId = await JobService.listCompletedJobs({
workspace: $workspaceStore!,
scriptPathExact: path,
jobKinds: ['preview', 'script', 'flowpreview', 'flow'].join(','),
@@ -6,7 +6,10 @@
type CompletedJob,
type ExtendedJobs,
ConcurrencyGroupsService,
type ObscuredJob
type ObscuredJob,
} from '$lib/gen'
import { sendUserToast } from '$lib/toast'
@@ -136,11 +139,32 @@
export async function loadExtraJobs(): Promise<boolean> {
if (jobs && jobs.length > 0) {
const lastJob = jobs[jobs.length - 1]
// const minCreated = lastJob?.created_at
const minCreated = new Date(new Date(lastJob.created_at!).getTime() - 1).toISOString()
let minQueueTs: string | undefined = undefined
let minCompletedTs: string | undefined = undefined
let olderJobs = await fetchJobs(undefined, minTs, undefined, minCreated)
let cursor = 0
while (jobs && cursor < jobs?.length) {
cursor++
const job = jobs[jobs.length - 1 - cursor]
if (job.type == 'CompletedJob') {
minCompletedTs = job.completed_at
break
} else if (job.type == 'QueuedJob' && minQueueTs == undefined) {
minQueueTs = job.created_at
}
}
const ts = minCompletedTs ?? minQueueTs
if (!ts) {
sendUserToast("No jobs to load from")
return false
}
// const minCreated = lastJob?.created_at
const minCreated = new Date(new Date(ts).getTime() - 1).toISOString()
let olderJobs = await fetchJobs(minCreated, minTs, undefined)
jobs = jobs.concat(olderJobs)
computeCompletedJobs()
return olderJobs?.length < 1000
@@ -149,10 +173,9 @@
}
async function fetchJobs(
startedBefore: string | undefined,
startedAfter: string | undefined,
startedAfterCompletedJobs: string | undefined,
createdBefore: string | undefined
completedBefore: string | undefined,
completedAfter: string | undefined,
createdAfterQueue: string | undefined
): Promise<Job[]> {
loadingFetch = true
try {
@@ -160,12 +183,11 @@
let scriptPathExact = path === null || path === '' ? undefined : path
return JobService.listJobs({
workspace: $workspaceStore!,
createdOrStartedBefore: startedBefore,
createdOrStartedAfter: startedAfter,
createdOrStartedAfterCompletedJobs: startedAfterCompletedJobs,
completedBefore,
completedAfter,
createdAfterQueue,
schedulePath,
scriptPathExact,
createdBefore,
createdBy: user === null || user === '' ? undefined : user,
scriptPathStart: scriptPathStart,
jobKinds: jobKindsCat == 'all' || jobKinds == '' ? undefined : jobKinds,
@@ -211,9 +233,8 @@
async function fetchExtendedJobs(
concurrencyKey: string | null,
startedBefore: string | undefined,
startedAfter: string | undefined,
startedAfterCompletedJobs: string | undefined
createdBeforeQueue: string | undefined,
completedAfter: string | undefined
): Promise<ExtendedJobs> {
loadingFetch = true
try {
@@ -221,9 +242,11 @@
rowLimit: 1000,
concurrencyKey: concurrencyKey == null || concurrencyKey == '' ? undefined : concurrencyKey,
workspace: $workspaceStore!,
createdOrStartedBefore: startedBefore,
createdOrStartedAfter: startedAfter,
createdOrStartedAfterCompletedJobs: startedAfterCompletedJobs,
completedAfter,
createdBeforeQueue: createdBeforeQueue,
// createdOrStartedBefore: startedBefore,
// createdOrStartedAfter: startedAfter,
// createdOrStartedAfterCompletedJobs: startedAfterCompletedJobs,
schedulePath,
scriptPathExact: path === null || path === '' ? undefined : path,
createdBy: user === null || user === '' ? undefined : user,
@@ -294,7 +317,7 @@
// lookback won't be needed anymore (just filter ended_at > minTs instead
const extendedMinTs = subtractDaysFromDateString(minTs, lookback)
if (concurrencyKey == null || concurrencyKey === '') {
let newJobs = await fetchJobs(maxTs, undefined, extendedMinTs, undefined)
let newJobs = await fetchJobs(maxTs, undefined, extendedMinTs)
extendedJobs = { jobs: newJobs, obscured_jobs: [] } as ExtendedJobs
// Filter on minTs here and not in the backend
@@ -302,7 +325,7 @@
jobs = sortMinDate(minTs, newJobs)
externalJobs = []
} else {
extendedJobs = await fetchExtendedJobs(concurrencyKey, maxTs, undefined, extendedMinTs)
extendedJobs = await fetchExtendedJobs(concurrencyKey, maxTs, extendedMinTs)
const newJobs = extendedJobs.jobs
const newExternalJobs = extendedJobs.obscured_jobs
@@ -351,6 +374,8 @@
}
}
let lastQueueTs: string | undefined = undefined
async function syncer() {
if (success == 'waiting') {
minTs = undefined
@@ -379,40 +404,47 @@
if (success == 'running') {
loadJobsIntern(false)
} else {
let ts: string | undefined = undefined
let minQueueCreatedAt: string | undefined = undefined
let completedTs: string | undefined = undefined
let cursor = 0
while (cursor < jobs.length && minTs == undefined) {
let invCursor = jobs.length - 1 - cursor
let isQueuedJob = invCursor == 0 || jobs[invCursor].type == 'QueuedJob'
if (isQueuedJob) {
if (cursor > 0) {
let inc = invCursor == 0 && jobs[invCursor].type == 'CompletedJob' ? 0 : 1
const date = new Date(jobs[invCursor + inc]?.created_at!)
date.setMilliseconds(date.getMilliseconds() + 1)
ts = date.toISOString()
if (minTs == undefined) {
while (cursor < jobs.length) {
const cjob = jobs[cursor]
if (cjob.type == 'QueuedJob') {
minQueueCreatedAt = cjob.created_at
} else if (cjob.type == 'CompletedJob' && completedTs == undefined) {
completedTs = new Date(cjob.completed_at!).toISOString()
}
break
cursor++
}
cursor++
}
let queueTs: string | undefined
if (minQueueCreatedAt) {
const queueTs = new Date(minQueueCreatedAt).toISOString()
lastQueueTs = queueTs
} else {
queueTs = lastQueueTs
}
loading = true
let newJobs: Job[]
if (concurrencyKey == null || concurrencyKey === '') {
newJobs = await fetchJobs(maxTs, minTs ?? ts, undefined, undefined)
newJobs = await fetchJobs(maxTs, minTs ?? completedTs, queueTs)
} else {
// Obscured jobs have no ids, so we have to do the full request
extendedJobs = await fetchExtendedJobs(concurrencyKey, maxTs, undefined, minTs ?? ts)
extendedJobs = await fetchExtendedJobs(concurrencyKey, maxTs, minTs ?? completedTs)
externalJobs = computeExternalJobs(extendedJobs.obscured_jobs)
// Filter on minTs here and not in the backend
// to get enough data for the concurrency graph
newJobs = sortMinDate(minTs ?? ts, extendedJobs.jobs)
newJobs = sortMinDate(minTs ?? completedTs, extendedJobs.jobs)
}
if (newJobs && newJobs.length > 0 && jobs) {
jobs = updateWithNewJobs(jobs, newJobs)
jobs = jobs
if (concurrencyKey == null || concurrencyKey === '') {
if (!extendedJobs) {
extendedJobs = { jobs: jobs, obscured_jobs: [] } as ExtendedJobs
@@ -3,12 +3,23 @@
import { Button } from '../common'
import { createEventDispatcher } from 'svelte'
export let minTs: string | undefined
export let maxTs: string | undefined
export let loading: boolean = false
export let selectedManualDate = 0
export let loadText: string | undefined = undefined
export let serviceLogsChoices: boolean = false
interface Props {
minTs: string | undefined;
maxTs: string | undefined;
loading?: boolean;
selectedManualDate?: number;
loadText?: string | undefined;
serviceLogsChoices?: boolean;
}
let {
minTs = $bindable(),
maxTs = $bindable(),
loading = false,
selectedManualDate = $bindable(0),
loadText = undefined,
serviceLogsChoices = false
}: Props = $props();
export function computeMinMax(): { minTs: string; maxTs: string | undefined } | undefined {
return manualDates[selectedManualDate].computeMinMax()
@@ -62,7 +73,7 @@
}
]
$: manualDates = [
let manualDates = $derived([
{
label: loadText ?? 'Last 1000 runs',
computeMinMax: () => {
@@ -70,7 +81,7 @@
}
},
...fixedManualDates
]
])
const dispatch = createEventDispatcher()
</script>
+10 -5
View File
@@ -181,9 +181,14 @@
<div class="overflow-hidden min-w-0">
<div class="flex flex-row items-center gap-1 text-secondary text-2xs">
{#if job}
{#if 'started_at' in job && job.started_at}
{isJobRecent ? 'Started' : ''}
<TimeAgo bind:isRecent={isJobRecent} agoOnlyIfRecent date={job.started_at ?? ''} />
{#if ('started_at' in job && job.started_at) || ('completed_at' in job && job.completed_at)}
{#if 'completed_at' in job && job.completed_at}
{isJobRecent ? 'Ended' : ''}
<TimeAgo bind:isRecent={isJobRecent} agoOnlyIfRecent date={job.completed_at ?? ''} />
{:else if 'started_at' in job && job.started_at}
{isJobRecent ? 'Started' : ''}
<TimeAgo bind:isRecent={isJobRecent} agoOnlyIfRecent date={job.started_at ?? ''} />
{/if}
{#if job && (job.self_wait_time_ms || job.aggregate_wait_time_ms)}
<WaitTimeWarning
self_wait_time_ms={job.self_wait_time_ms}
@@ -200,12 +205,12 @@
Cancelling job... (created <TimeAgo agoOnlyIfRecent date={job.created_at || ''} />)
{/if}
{:else if `scheduled_for` in job && job.scheduled_for && forLater(job.scheduled_for)}
Waiting for executor (scheduled for <TimeAgo
Waiting executor (<TimeAgo
agoOnlyIfRecent
date={job.scheduled_for || ''}
/>)
{:else}
Waiting for executor (created <TimeAgo agoOnlyIfRecent date={job.created_at || ''} />)
Waiting executor (<TimeAgo agoOnlyIfRecent date={job.created_at || ''} />)
{/if}
{/if}
</div>
@@ -37,7 +37,7 @@
}: Props = $props()
function getTime(job: Job): string | undefined {
return job['started_at'] ?? job['scheduled_for'] ?? job['created_at']
return job['completed_at'] ?? job['started_at'] ?? job['scheduled_for'] ?? job['created_at']
}
function groupJobsByDay(jobs: Job[]): {
@@ -109,7 +109,7 @@ export class StepHistoryLoader {
? module.value.path
: (initialPath === '' ? path : initialPath) + '/' + module.id
const previousJobId = await JobService.listJobs({
const previousJobId = await JobService.listCompletedJobs({
workspace: workspaceId,
scriptPathExact: scriptPath,
jobKinds: ['preview', 'script', 'flowpreview', 'flow', 'flowscript'].join(','),