Files
windmill/backend/windmill-common/src/assets.rs
T
Ruben FiszelandClaude Opus 4.8 5970510cf6 perf(backend): gate asset producer-change event on write-set changes (#9672)
* perf(backend): only emit asset producer-change event on write-set changes

Data Pipelines (#9193) made every script deploy emit a
`notify_asset_producer_change` event: `clear_static_asset_usage` inserted
into `notify_event` unconditionally on every clear, and the per-asset
insert path emitted nothing. So a plain script with no assets — the
overwhelming majority of deploys — wrote a `notify_event` row that made
every worker drop its `ASSET_PRODUCER_WRITES_CACHE` entry instance-wide,
needlessly thrashing the cache the feature added.

That cache only tracks script rows with write access (`usage_access_type
IN ('w','rw')`), so a deploy changes it only when the script gains or
loses a write producer. Gate the event on exactly that:

- `clear_static_asset_usage` / `clear_static_asset_usage_by_script_hash`
  emit only when the delete removed a 'w'/'rw' row (via `RETURNING`).
- `insert_static_asset_usage` emits only when it actually inserts a
  'w'/'rw' script row (no-op `ON CONFLICT`, read-only, and flow usage
  stay silent).

Plain, read-only, and flow deploys now emit nothing; producer-changing
deploys still invalidate, atomically and visible-only-on-commit as
before. Adds a test asserting the emit/no-emit matrix.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* perf(backend): dedup producer-change notify on write-asset redeploys

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(backend): derive replace write-set from persisted rows; document auth contract

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* docs(backend): correct replace_static_asset_usage call-site comment

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 13:49:32 +00:00

393 lines
15 KiB
Rust

use std::collections::HashSet;
use sqlx::{PgExecutor, Postgres, Transaction};
use crate::{error, scripts::ScriptHash};
pub use windmill_parser::asset_parser::{parse_pipeline_annotations, TriggerSpec, PARTITION_TOKEN};
pub use windmill_types::assets::*;
#[derive(sqlx::Type, Debug, Clone, Copy, PartialEq)]
#[sqlx(type_name = "SCRIPT_TRIGGER_KIND", rename_all = "lowercase")]
pub enum ScriptTriggerKind {
Asset,
Schedule,
Webhook,
Email,
Kafka,
Mqtt,
Nats,
Postgres,
Sqs,
Gcp,
}
pub async fn insert_static_asset_usage<'e>(
executor: impl PgExecutor<'e>,
workspace_id: &str,
asset: &AssetWithAltAccessType,
usage_path: &str,
usage_kind: AssetUsageKind,
) -> error::Result<()> {
// Convert columns BTreeMap to JSONB format
let columns_json = asset
.columns
.as_ref()
.map(|cols| serde_json::to_value(cols).unwrap_or(serde_json::Value::Null));
// Invalidate the per-workspace producer-writes cache only when this insert
// actually adds a write producer: the cache (asset_dispatch::
// ASSET_PRODUCER_WRITES_CACHE) tracks script rows with 'w'/'rw' access, so
// a row that was a no-op (ON CONFLICT skipped), a flow usage, or read-only
// can't change it. Emitting in the same statement keeps the notify atomic
// with the insert and visible to pollers only on commit. See the matching
// delete-side guard in clear_static_asset_usage.
sqlx::query!(
r#"WITH ins AS (
INSERT INTO asset (workspace_id, path, kind, usage_access_type, usage_path, usage_kind, columns)
VALUES ($1, $2, $3, $4, $5, $6, $7) ON CONFLICT DO NOTHING
RETURNING usage_kind, usage_access_type
)
INSERT INTO notify_event (channel, payload)
SELECT 'notify_asset_producer_change', $1
FROM ins WHERE usage_kind = 'script' AND usage_access_type IN ('w', 'rw')"#,
workspace_id,
asset.path,
asset.kind as AssetKind,
(asset.access_type.or(asset.alt_access_type)) as Option<AssetUsageAccessType>,
usage_path,
usage_kind as AssetUsageKind,
columns_json as Option<serde_json::Value>
)
.execute(executor)
.await?;
Ok(())
}
pub async fn clear_static_asset_usage<'e>(
executor: impl PgExecutor<'e>,
workspace_id: &str,
usage_path: &str,
usage_kind: AssetUsageKind,
) -> error::Result<()> {
// Invalidate the per-workspace producer-writes cache that gates the
// asset-trigger dispatch hook (windmill-queue
// asset_dispatch::ASSET_PRODUCER_WRITES_CACHE). That cache only tracks
// script rows with 'w'/'rw' access, so emit the notify only when the
// delete actually removed such a write producer: flow usage, read-only
// usage, and deletes that matched no producer row leave the cache
// unchanged (the common case — most deploys touch no write asset). The
// matching add side lives in insert_static_asset_usage. Emitting in the
// same statement keeps the notify atomic with the delete and visible to
// pollers only on commit.
sqlx::query!(
r#"WITH del AS (
DELETE FROM asset WHERE workspace_id = $1 AND usage_path = $2 AND usage_kind = $3
RETURNING usage_access_type
)
INSERT INTO notify_event (channel, payload)
SELECT 'notify_asset_producer_change', $1
WHERE $3 = 'script'
AND EXISTS (SELECT 1 FROM del WHERE usage_access_type IN ('w', 'rw'))"#,
workspace_id,
usage_path,
usage_kind as AssetUsageKind
)
.execute(executor)
.await?;
Ok(())
}
pub async fn clear_static_asset_usage_by_script_hash<'e>(
executor: impl PgExecutor<'e>,
workspace_id: &str,
script_hash: ScriptHash,
) -> error::Result<()> {
// Always script usage → invalidate the producer-writes cache for this
// workspace, but only when the delete actually removed a write producer
// ('w'/'rw'); see clear_static_asset_usage. Atomic with the delete.
sqlx::query!(
r#"WITH del AS (
DELETE FROM asset WHERE workspace_id = $1 AND usage_kind = 'script'
AND usage_path = (SELECT path FROM script WHERE hash = $2 AND workspace_id = $1)
RETURNING usage_access_type
)
INSERT INTO notify_event (channel, payload)
SELECT 'notify_asset_producer_change', $1
WHERE EXISTS (SELECT 1 FROM del WHERE usage_access_type IN ('w', 'rw'))"#,
workspace_id,
script_hash.0
)
.execute(executor)
.await?;
Ok(())
}
fn is_write_access(access: Option<AssetUsageAccessType>) -> bool {
matches!(
access,
Some(AssetUsageAccessType::W) | Some(AssetUsageAccessType::RW)
)
}
/// Clear and reinsert the full static-asset usage set of a script in one tx,
/// invalidating the producer-writes cache at most once and only on a real
/// change. The cache (asset_dispatch::ASSET_PRODUCER_WRITES_CACHE) keys a
/// workspace by the set of (kind, path) script rows with 'w'/'rw' access, so a
/// redeploy that keeps the same write producers must leave it untouched. We
/// diff the old write set (captured from the clearing DELETE's RETURNING)
/// against the new one and emit a single notify only when they differ —
/// emitting per statement, as clear/insert_static_asset_usage do, would fire
/// twice on every write-asset redeploy (the clear removes the row, the reinsert
/// adds it back). The notify rides the deploy tx, so it stays atomic with the
/// DML and visible to pollers only on commit.
///
/// DELETE and INSERT of the same primary key cannot share a single statement
/// (both would read the pre-statement snapshot, so the reinsert's ON CONFLICT
/// would silently drop the row), which is why this clears and reinserts as
/// separate statements rather than one CTE.
///
/// Performs no authorization itself: `tx` must already be scoped to a caller
/// authorized for `workspace_id`/`usage_path` (e.g. `user_db.begin(&authed)`,
/// which applies RLS), exactly like the sibling clear/insert helpers.
pub async fn replace_static_asset_usage(
tx: &mut Transaction<'_, Postgres>,
workspace_id: &str,
usage_path: &str,
assets: &[AssetWithAltAccessType],
) -> error::Result<()> {
let cleared = sqlx::query!(
r#"DELETE FROM asset
WHERE workspace_id = $1 AND usage_path = $2 AND usage_kind = 'script'
RETURNING kind AS "kind!: AssetKind", path,
usage_access_type AS "usage_access_type: AssetUsageAccessType""#,
workspace_id,
usage_path,
)
.fetch_all(&mut **tx)
.await?;
let old_writes: HashSet<(AssetKind, String)> = cleared
.into_iter()
.filter(|r| is_write_access(r.usage_access_type))
.map(|r| (r.kind, r.path))
.collect();
// Build new_writes from rows actually inserted (RETURNING), not the
// requested slice: ON CONFLICT DO NOTHING is first-writer-wins, so a payload
// with duplicate (kind, path) entries at conflicting access types persists
// only the first. Since the DELETE above emptied this usage_path, every
// non-conflicting insert lands, so the inserted rows are exactly the new
// persisted set.
let mut new_writes: HashSet<(AssetKind, String)> = HashSet::new();
for asset in assets {
let access = asset.access_type.or(asset.alt_access_type);
let columns_json = asset
.columns
.as_ref()
.map(|cols| serde_json::to_value(cols).unwrap_or(serde_json::Value::Null));
let inserted = sqlx::query!(
r#"INSERT INTO asset (workspace_id, path, kind, usage_access_type, usage_path, usage_kind, columns)
VALUES ($1, $2, $3, $4, $5, 'script', $6) ON CONFLICT DO NOTHING
RETURNING usage_access_type AS "usage_access_type: AssetUsageAccessType""#,
workspace_id,
asset.path,
asset.kind as AssetKind,
access as Option<AssetUsageAccessType>,
usage_path,
columns_json as Option<serde_json::Value>,
)
.fetch_optional(&mut **tx)
.await?;
if let Some(row) = inserted {
if is_write_access(row.usage_access_type) {
new_writes.insert((asset.kind, asset.path.clone()));
}
}
}
if old_writes != new_writes {
sqlx::query!(
r#"INSERT INTO notify_event (channel, payload)
VALUES ('notify_asset_producer_change', $1)"#,
workspace_id,
)
.execute(&mut **tx)
.await?;
}
Ok(())
}
// Wipe all pipeline trigger declarations held by the given runnable. Used at
// deploy time: redeploying a script wipes its prior `// on` annotations so
// removing them implicitly un-declares those edges.
pub async fn clear_script_triggers<'e>(
executor: impl PgExecutor<'e>,
workspace_id: &str,
runnable_path: &str,
runnable_kind: AssetUsageKind,
) -> error::Result<()> {
sqlx::query!(
r#"DELETE FROM script_trigger
WHERE workspace_id = $1 AND runnable_kind = $2 AND runnable_path = $3"#,
workspace_id,
runnable_kind as AssetUsageKind,
runnable_path,
)
.execute(executor)
.await?;
Ok(())
}
// Insert a single trigger declaration. Caller is expected to wipe first.
// `join_all` is the script-level `// trigger all` flag (AND join barrier);
// `retry_count` / `retry_delay_s` are the `// retry <n> [<delay>]` policy.
// All three are script-level — the same value for every row of a given
// runnable — but stored per-row to keep the wipe-and-reinsert pattern and a
// single-query subscriber lookup.
pub async fn insert_script_trigger<'e>(
executor: impl PgExecutor<'e>,
workspace_id: &str,
runnable_kind: AssetUsageKind,
runnable_path: &str,
trigger_kind: ScriptTriggerKind,
trigger_ref: &str,
join_all: bool,
debounce_s: Option<i32>,
retry_count: Option<i16>,
retry_delay_s: Option<i32>,
) -> error::Result<()> {
sqlx::query!(
r#"INSERT INTO script_trigger
(workspace_id, runnable_kind, runnable_path, trigger_kind, trigger_ref, join_all,
debounce_s, retry_count, retry_delay_s)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)"#,
workspace_id,
runnable_kind as AssetUsageKind,
runnable_path,
trigger_kind as ScriptTriggerKind,
trigger_ref,
join_all,
debounce_s,
retry_count,
retry_delay_s,
)
.execute(executor)
.await?;
Ok(())
}
/// Parse a debounce duration into whole seconds. Accepts a bare integer
/// (seconds) or an `<n>` with an `s`/`m`/`h`/`d` suffix (e.g. `30s`,
/// `5m`, `2h`, `1d`). Returns `None` for empty / malformed / non-positive
/// input — the caller treats `None` as "no debounce" (fan-out), so a typo
/// fails safe rather than silently debouncing.
pub fn parse_duration_secs(s: &str) -> Option<i32> {
let s = s.trim();
if s.is_empty() {
return None;
}
let (num, mult): (&str, i64) = match s.as_bytes().last() {
Some(b's') => (&s[..s.len() - 1], 1),
Some(b'm') => (&s[..s.len() - 1], 60),
Some(b'h') => (&s[..s.len() - 1], 3600),
Some(b'd') => (&s[..s.len() - 1], 86400),
Some(c) if c.is_ascii_digit() => (s, 1),
_ => return None,
};
let n: i64 = num.trim().parse().ok()?;
let secs = n.checked_mul(mult)?;
if secs <= 0 || secs > i32::MAX as i64 {
return None;
}
Some(secs as i32)
}
#[cfg(test)]
mod debounce_duration_tests {
use super::parse_duration_secs;
#[test]
fn parses_units_and_bare_seconds() {
assert_eq!(parse_duration_secs("60"), Some(60));
assert_eq!(parse_duration_secs("30s"), Some(30));
assert_eq!(parse_duration_secs("5m"), Some(300));
assert_eq!(parse_duration_secs("2h"), Some(7200));
assert_eq!(parse_duration_secs(" 1d "), Some(86400));
}
#[test]
fn rejects_garbage_and_nonpositive() {
assert_eq!(parse_duration_secs(""), None);
assert_eq!(parse_duration_secs("abc"), None);
assert_eq!(parse_duration_secs("0"), None);
assert_eq!(parse_duration_secs("-5"), None);
assert_eq!(parse_duration_secs("10x"), None);
assert_eq!(parse_duration_secs("s"), None);
}
}
// Inverse of trigger_spec_to_row for the Asset variant: parses a stored
// trigger_ref (e.g. `s3://foo`, `$res:bar`) back into the (kind, path) pair
// used as a graph node id. Returns None for refs that don't match any known
// asset prefix — callers should skip those edges.
pub fn parse_asset_trigger_ref(s: &str) -> Option<(AssetKind, String)> {
let (kind, path) = windmill_parser::asset_parser::parse_asset_syntax(s, false)?;
Some((asset_kind_from_parser(kind), path.to_string()))
}
// Convert a parser TriggerSpec into the `(kind, ref)` pair stored in
// script_trigger. Asset refs get their canonical prefix back so the
// trigger_ref matches what downstream lookups expect.
//
// Returns `None` for native trigger kinds (Kafka, Mqtt, Postgres, …) —
// those annotations are marker-only and don't produce a `script_trigger`
// row. The actual binding lives on the trigger row's own `script_path`
// column; the graph endpoint looks it up directly per kind.
pub fn trigger_spec_to_row(spec: &TriggerSpec) -> Option<(ScriptTriggerKind, String)> {
match spec {
TriggerSpec::Asset { asset_kind, path, .. } => {
// Single source of truth for the canonical prefix lives on the
// common AssetKind; map the parser kind across first. The parser
// enum has no Variable variant, so canonical_prefix is always Some.
let prefix = asset_kind_from_parser(*asset_kind).canonical_prefix()?;
Some((ScriptTriggerKind::Asset, format!("{}{}", prefix, path)))
}
// Schedule joins the native-trigger family — no script_trigger row
// is inserted for the annotation. The binding lives on the schedule
// row's own `script_path` field, same as kafka/mqtt/etc.
TriggerSpec::Schedule
| TriggerSpec::Webhook
| TriggerSpec::Email
| TriggerSpec::Kafka
| TriggerSpec::Mqtt
| TriggerSpec::Nats
| TriggerSpec::Postgres
| TriggerSpec::Sqs
| TriggerSpec::Gcp
// data_upload is a UI-first marker — no event source, no trigger row.
// The script's S3Object input + auto-generated S3 picker drive it.
| TriggerSpec::DataUpload => None,
}
}
pub fn asset_kind_from_parser(parser_kind: windmill_parser::asset_parser::AssetKind) -> AssetKind {
match parser_kind {
windmill_parser::asset_parser::AssetKind::S3Object => AssetKind::S3Object,
windmill_parser::asset_parser::AssetKind::Resource => AssetKind::Resource,
windmill_parser::asset_parser::AssetKind::Ducklake => AssetKind::Ducklake,
windmill_parser::asset_parser::AssetKind::DataTable => AssetKind::DataTable,
windmill_parser::asset_parser::AssetKind::Volume => AssetKind::Volume,
}
}
pub fn asset_access_type_from_parser(
parser_kind: windmill_parser::asset_parser::AssetUsageAccessType,
) -> AssetUsageAccessType {
match parser_kind {
windmill_parser::asset_parser::AssetUsageAccessType::R => AssetUsageAccessType::R,
windmill_parser::asset_parser::AssetUsageAccessType::W => AssetUsageAccessType::W,
windmill_parser::asset_parser::AssetUsageAccessType::RW => AssetUsageAccessType::RW,
}
}