mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-09-21 00:02:30 +00:00
refactor: measure drift only when a schedule editor is opened
This commit is contained in:
-57
@@ -1,57 +0,0 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "SELECT\n schedule.path, schedule.schedule, schedule.timezone,\n schedule.cron_version, t.jobs, p.push_times 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 LATERAL(SELECT ARRAY(\n SELECT created_at FROM (\n SELECT created_at, trigger, trigger_kind\n FROM v2_job\n WHERE schedule.enabled\n AND v2_job.workspace_id = $1\n AND parent_job IS NULL AND runnable_path = schedule.script_path\n AND created_at > schedule.edited_at\n ORDER BY created_at DESC\n LIMIT $6\n ) recent\n WHERE trigger_kind = 'schedule' AND trigger = schedule.path\n ORDER BY created_at DESC\n LIMIT $5\n ) AS push_times) p\n WHERE workspace_id = $1 AND NOT starts_with(schedule.path, $4)\n ORDER BY edited_at DESC\n LIMIT $2 OFFSET $3",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "path",
|
||||
"type_info": "Varchar"
|
||||
},
|
||||
{
|
||||
"ordinal": 1,
|
||||
"name": "schedule",
|
||||
"type_info": "Varchar"
|
||||
},
|
||||
{
|
||||
"ordinal": 2,
|
||||
"name": "timezone",
|
||||
"type_info": "Varchar"
|
||||
},
|
||||
{
|
||||
"ordinal": 3,
|
||||
"name": "cron_version",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 4,
|
||||
"name": "jobs",
|
||||
"type_info": "JsonArray"
|
||||
},
|
||||
{
|
||||
"ordinal": 5,
|
||||
"name": "push_times",
|
||||
"type_info": "TimestamptzArray"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text",
|
||||
"Int8",
|
||||
"Int8",
|
||||
"Text",
|
||||
"Int8",
|
||||
"Int8"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
true,
|
||||
null,
|
||||
null
|
||||
]
|
||||
},
|
||||
"hash": "7d67aebe32cc5310a92f510bd2dd742198c8a492783348d457e6811601b21371"
|
||||
}
|
||||
+24
@@ -0,0 +1,24 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "SELECT created_at\n FROM trigger_history\n WHERE workspace_id = $1 AND trigger_kind = $2 AND path = $3\n AND (changes ?| array['schedule', 'timezone', 'cron_version']\n OR changes -> 'truncated_fields' ?| array['schedule', 'timezone', 'cron_version'])\n ORDER BY id DESC\n LIMIT 1",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "created_at",
|
||||
"type_info": "Timestamptz"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text",
|
||||
"Text",
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "a9db62e3a7465feec93ff9b09142807ed1db0bb40ca8a7d345afbaaf15ef5d16"
|
||||
}
|
||||
-30
@@ -1,30 +0,0 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "SELECT DISTINCT ON (path) path, created_at\n FROM trigger_history\n WHERE workspace_id = $1 AND trigger_kind = $2 AND path = ANY($3)\n AND (changes ?| array['schedule', 'timezone', 'cron_version']\n OR changes -> 'truncated_fields' ?| array['schedule', 'timezone', 'cron_version'])\n ORDER BY path, id DESC",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "path",
|
||||
"type_info": "Varchar"
|
||||
},
|
||||
{
|
||||
"ordinal": 1,
|
||||
"name": "created_at",
|
||||
"type_info": "Timestamptz"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text",
|
||||
"Text",
|
||||
"TextArray"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
false,
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "c2aa552cf5f74d1a1305709c576425c3749b9c66cef9a4c4675ed6f6dfe72d6f"
|
||||
}
|
||||
@@ -1000,8 +1000,6 @@ async fn list_schedule(
|
||||
pub struct ScheduleWJobs {
|
||||
pub path: String,
|
||||
pub jobs: Option<Vec<serde_json::Value>>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub interval_drift: Option<IntervalDrift>,
|
||||
}
|
||||
|
||||
/// How often a schedule's runs are actually landing, reported only while that
|
||||
@@ -1025,9 +1023,11 @@ const DRIFT_SAMPLE_SIZE: i64 = DRIFT_MIN_MISSED_SLOTS as i64 + 1;
|
||||
/// Root jobs of the runnable the sample may walk before giving up. There is no
|
||||
/// index on `trigger`, so the sample rides the one on `runnable_path` and finds
|
||||
/// the schedule's own runs by filter: a runnable that is also busy outside this
|
||||
/// schedule would otherwise be read back as far as its fourth scheduled run.
|
||||
/// Past the budget the schedule reports nothing rather than costing the walk.
|
||||
const DRIFT_SCAN_BUDGET: i64 = 100;
|
||||
/// schedule would otherwise be read back as far as its fourth scheduled run,
|
||||
/// which for a slow schedule on a busy script is unbounded. This covers that
|
||||
/// case with room to spare, and is affordable because the sample is only taken
|
||||
/// when someone opens one schedule, never once per row of a listing.
|
||||
const DRIFT_SCAN_BUDGET: i64 = 2000;
|
||||
|
||||
/// `push_times` are the moments the last runs were queued, newest first. The
|
||||
/// pusher anchors `find_next` on exactly that moment, so recomputing from it
|
||||
@@ -1082,8 +1082,7 @@ fn detect_interval_drift(
|
||||
mildest
|
||||
}
|
||||
|
||||
/// When each schedule's cron expression last changed, for the paths asked
|
||||
/// about and only those that ever recorded such a change.
|
||||
/// When this schedule's cron expression last changed, if it ever has.
|
||||
///
|
||||
/// Runs queued under a previous expression measure as drift against the one
|
||||
/// that replaced it, so a sample reaching back past the change has to be
|
||||
@@ -1091,66 +1090,47 @@ fn detect_interval_drift(
|
||||
/// after the insert. The trigger history can, and is only read once a sample
|
||||
/// has already come out as drifting.
|
||||
///
|
||||
/// Compare strictly: the edit queues a tick of its own in the same
|
||||
/// Compare strictly against it: the edit queues a tick of its own in the same
|
||||
/// transaction, so that first run under the new expression carries the very
|
||||
/// timestamp recorded here.
|
||||
///
|
||||
/// Reads on the privileged pool: the paths reaching here have already come
|
||||
/// back from an RLS read of their own schedule row and passed the caller's
|
||||
/// read scope.
|
||||
async fn cron_changed_at(
|
||||
db: &DB,
|
||||
w_id: &str,
|
||||
paths: &[String],
|
||||
) -> Result<Vec<(String, DateTime<Utc>)>> {
|
||||
let rows = sqlx::query!(
|
||||
/// Reads on the privileged pool, which the caller has earned with an RLS read
|
||||
/// of the schedule row itself.
|
||||
async fn cron_changed_at(db: &DB, w_id: &str, path: &str) -> Result<Option<DateTime<Utc>>> {
|
||||
let changed_at = sqlx::query_scalar!(
|
||||
// An entry too large to record field by field keeps only the names, so both
|
||||
// shapes have to be asked.
|
||||
"SELECT DISTINCT ON (path) path, created_at
|
||||
"SELECT created_at
|
||||
FROM trigger_history
|
||||
WHERE workspace_id = $1 AND trigger_kind = $2 AND path = ANY($3)
|
||||
WHERE workspace_id = $1 AND trigger_kind = $2 AND path = $3
|
||||
AND (changes ?| array['schedule', 'timezone', 'cron_version']
|
||||
OR changes -> 'truncated_fields' ?| array['schedule', 'timezone', 'cron_version'])
|
||||
ORDER BY path, id DESC",
|
||||
ORDER BY id DESC
|
||||
LIMIT 1",
|
||||
w_id,
|
||||
SCHEDULE_TRIGGER_KIND,
|
||||
paths
|
||||
path
|
||||
)
|
||||
.fetch_all(db)
|
||||
.fetch_optional(db)
|
||||
.await?;
|
||||
Ok(rows.into_iter().map(|r| (r.path, r.created_at)).collect())
|
||||
Ok(changed_at)
|
||||
}
|
||||
|
||||
async fn list_schedule_with_jobs(
|
||||
authed: ApiAuthed,
|
||||
Extension(user_db): Extension<UserDB>,
|
||||
Extension(db): Extension<DB>,
|
||||
Path(w_id): Path<String>,
|
||||
Query(pagination): Query<Pagination>,
|
||||
) -> JsonResult<Vec<ScheduleWJobs>> {
|
||||
let mut tx = user_db.begin(&authed).await?;
|
||||
let (per_page, offset) = paginate(pagination);
|
||||
struct ScheduleWJobsRow {
|
||||
path: String,
|
||||
jobs: Option<Vec<serde_json::Value>>,
|
||||
push_times: Option<Vec<DateTime<Utc>>>,
|
||||
schedule: String,
|
||||
timezone: String,
|
||||
cron_version: Option<String>,
|
||||
}
|
||||
let rows = sqlx::query_as!(ScheduleWJobsRow,
|
||||
let rows = sqlx::query_as!(ScheduleWJobs,
|
||||
// Query plan:
|
||||
// - use of the `ix_completed_job_workspace_id_started_at_new_2` index first, then;
|
||||
// - use of the `ix_v2_job_root_by_path` index; hence the `parent_job IS NULL` clause.
|
||||
// - both `workspace_id = $1` checks are required to hit both indexes.
|
||||
// - `push_times` rides the `(workspace_id, runnable_path, created_at DESC)` index,
|
||||
// hence its own `parent_job IS NULL` clause, and is capped by DRIFT_SCAN_BUDGET.
|
||||
// The `edited_at` bound is not that cap: it drops the runs of whatever schedule
|
||||
// last held this path, which carry the same `trigger`. Nothing writes `edited_at`
|
||||
// after the insert, so no run of this schedule can predate it.
|
||||
"SELECT
|
||||
schedule.path, schedule.schedule, schedule.timezone,
|
||||
schedule.cron_version, t.jobs, p.push_times FROM schedule,
|
||||
schedule.path, t.jobs FROM schedule,
|
||||
LATERAL(SELECT ARRAY(
|
||||
SELECT json_build_object('id', id, 'success', status = 'success', 'duration_ms', duration_ms)
|
||||
FROM v2_job_completed c JOIN v2_job j USING (id)
|
||||
@@ -1162,71 +1142,22 @@ async fn list_schedule_with_jobs(
|
||||
AND status <> 'skipped'
|
||||
ORDER BY completed_at DESC
|
||||
LIMIT 20
|
||||
) AS jobs) t,
|
||||
LATERAL(SELECT ARRAY(
|
||||
SELECT created_at FROM (
|
||||
SELECT created_at, trigger, trigger_kind
|
||||
FROM v2_job
|
||||
WHERE schedule.enabled
|
||||
AND v2_job.workspace_id = $1
|
||||
AND parent_job IS NULL AND runnable_path = schedule.script_path
|
||||
AND created_at > schedule.edited_at
|
||||
ORDER BY created_at DESC
|
||||
LIMIT $6
|
||||
) recent
|
||||
WHERE trigger_kind = 'schedule' AND trigger = schedule.path
|
||||
ORDER BY created_at DESC
|
||||
LIMIT $5
|
||||
) AS push_times) p
|
||||
) AS jobs) t
|
||||
WHERE workspace_id = $1 AND NOT starts_with(schedule.path, $4)
|
||||
ORDER BY edited_at DESC
|
||||
LIMIT $2 OFFSET $3",
|
||||
w_id,
|
||||
per_page as i64,
|
||||
offset as i64,
|
||||
windmill_common::workspaces::DUCKLAKE_MAINTENANCE_PATH_PREFIX,
|
||||
DRIFT_SAMPLE_SIZE,
|
||||
DRIFT_SCAN_BUDGET
|
||||
windmill_common::workspaces::DUCKLAKE_MAINTENANCE_PATH_PREFIX
|
||||
)
|
||||
.fetch_all(&mut *tx)
|
||||
.await?;
|
||||
tx.commit().await?;
|
||||
let allowed = build_scope_path_predicate(&authed, "schedules", "read");
|
||||
|
||||
let mut out: Vec<ScheduleWJobs> = Vec::with_capacity(rows.len());
|
||||
// Oldest run each reported drift rests on, kept to check it against the
|
||||
// cron's own history below.
|
||||
let mut drifting: Vec<(String, DateTime<Utc>)> = Vec::new();
|
||||
for r in rows.into_iter().filter(|r| allowed(&r.path)) {
|
||||
let sample = r.push_times.unwrap_or_default();
|
||||
let interval_drift =
|
||||
detect_interval_drift(&sample, &r.schedule, r.cron_version.as_deref(), &r.timezone);
|
||||
if interval_drift.is_some() {
|
||||
if let Some(oldest) = sample.last() {
|
||||
drifting.push((r.path.clone(), *oldest));
|
||||
}
|
||||
}
|
||||
out.push(ScheduleWJobs { path: r.path, jobs: r.jobs, interval_drift });
|
||||
}
|
||||
|
||||
if !drifting.is_empty() {
|
||||
let paths = drifting
|
||||
.iter()
|
||||
.map(|(path, _)| path.clone())
|
||||
.collect::<Vec<_>>();
|
||||
for (path, changed_at) in cron_changed_at(&db, &w_id, &paths).await? {
|
||||
if drifting
|
||||
.iter()
|
||||
.any(|(p, oldest)| *p == path && *oldest < changed_at)
|
||||
{
|
||||
if let Some(row) = out.iter_mut().find(|r| r.path == path) {
|
||||
row.interval_drift = None;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(Json(out))
|
||||
Ok(Json(
|
||||
rows.into_iter().filter(|r| allowed(&r.path)).collect(),
|
||||
))
|
||||
}
|
||||
|
||||
// SELECT id, title AS item_title, t.tag_array
|
||||
@@ -1324,8 +1255,8 @@ async fn fetch_interval_drift(
|
||||
);
|
||||
if drift.is_some() {
|
||||
if let Some(oldest) = push_times.last() {
|
||||
let changed = cron_changed_at(db, w_id, std::slice::from_ref(&schedule.path)).await?;
|
||||
if changed.iter().any(|(_, changed_at)| *oldest < *changed_at) {
|
||||
let changed_at = cron_changed_at(db, w_id, &schedule.path).await?;
|
||||
if changed_at.is_some_and(|changed_at| *oldest < changed_at) {
|
||||
return Ok(None);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -29775,8 +29775,6 @@ components:
|
||||
- id
|
||||
- success
|
||||
- duration_ms
|
||||
interval_drift:
|
||||
$ref: "#/components/schemas/ScheduleIntervalDrift"
|
||||
|
||||
ScheduleIntervalDrift:
|
||||
type: object
|
||||
|
||||
@@ -511,6 +511,9 @@
|
||||
async function loadSchedule(
|
||||
defaultCfg?: Record<string, any>
|
||||
): Promise<{ overlay: Record<string, any> | undefined; noDeployed: boolean }> {
|
||||
// Reading the deployed row is the only thing that carries a measurement, and
|
||||
// it omits the field when the schedule is keeping up.
|
||||
intervalDrift = undefined
|
||||
if (defaultCfg) {
|
||||
await loadScheduleCfg(defaultCfg)
|
||||
return { overlay: undefined, noDeployed: false }
|
||||
@@ -539,7 +542,11 @@
|
||||
async function loadScheduleCfg(cfg: Record<string, any>): Promise<void> {
|
||||
loading = true
|
||||
|
||||
intervalDrift = cfg.interval_drift ?? undefined
|
||||
// Config snapshots from draftSync carry no measurement, and applying one
|
||||
// leaves the deployed schedule running exactly as it was.
|
||||
if ('interval_drift' in cfg) {
|
||||
intervalDrift = cfg.interval_drift
|
||||
}
|
||||
cronVersion = cfg.cron_version ?? 'v2'
|
||||
initialCronVersion = cronVersion
|
||||
isLatestCron = cronVersion == 'v2'
|
||||
|
||||
@@ -6,14 +6,7 @@
|
||||
type WorkspaceDeployUISettings,
|
||||
WorkspaceService
|
||||
} from '$lib/gen'
|
||||
import {
|
||||
canWrite,
|
||||
displayDate,
|
||||
getLocalSetting,
|
||||
msToReadableTime,
|
||||
msToReadableTimeShort,
|
||||
storeLocalSetting
|
||||
} from '$lib/utils'
|
||||
import { canWrite, displayDate, getLocalSetting, storeLocalSetting } from '$lib/utils'
|
||||
import { withForkConflictRetry } from '$lib/utils/forkConflict'
|
||||
import { base } from '$app/paths'
|
||||
import CenteredPage from '$lib/components/CenteredPage.svelte'
|
||||
@@ -156,7 +149,6 @@
|
||||
for (let schedule of schedules) {
|
||||
if (schedulesWithJobsByPath[schedule.path]) {
|
||||
schedule.jobs = schedulesWithJobsByPath[schedule.path].jobs
|
||||
schedule.interval_drift = schedulesWithJobsByPath[schedule.path].interval_drift
|
||||
}
|
||||
}
|
||||
loadingSchedulesWithJobStats = false
|
||||
@@ -408,7 +400,7 @@
|
||||
{/if}
|
||||
{:else if items?.length}
|
||||
<div class="border rounded-md divide-y">
|
||||
{#each items.slice(0, nbDisplayed) as { path, error, summary, edited_by, edited_at, schedule, timezone, enabled, script_path, is_flow, extra_perms, canWrite, jobs, interval_drift, paused_until, labels, inherited_labels, draft_only, is_draft } (path)}
|
||||
{#each items.slice(0, nbDisplayed) as { path, error, summary, edited_by, edited_at, schedule, timezone, enabled, script_path, is_flow, extra_perms, canWrite, jobs, paused_until, labels, inherited_labels, draft_only, is_draft } (path)}
|
||||
{@const hasDraft =
|
||||
getLocalDraftHint($workspaceStore, 'trigger_schedule', path) ?? is_draft}
|
||||
{@const href = `${is_flow ? '/flows/get' : '/scripts/get'}/${script_path}`}
|
||||
@@ -474,23 +466,6 @@
|
||||
<div class="gap-2 items-center hidden md:flex">
|
||||
<Badge large color="blue">{schedule}</Badge>
|
||||
<Badge small color="gray">{timezone}</Badge>
|
||||
{#if interval_drift}
|
||||
<Popover notClickable>
|
||||
<Badge small color="yellow"
|
||||
>every ~{msToReadableTimeShort(interval_drift.effective_s * 1000)}</Badge
|
||||
>
|
||||
{#snippet text()}
|
||||
<div>
|
||||
This schedule is running about every {msToReadableTime(
|
||||
interval_drift.effective_s * 1000
|
||||
)} instead of every {msToReadableTime(
|
||||
interval_drift.configured_s * 1000
|
||||
)}: each of the last runs was queued too late for the slot that would have
|
||||
kept the cadence.
|
||||
</div>
|
||||
{/snippet}
|
||||
</Popover>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="hidden lg:flex flex-row gap-1 items-center">
|
||||
|
||||
Reference in New Issue
Block a user