Files
windmill/backend/windmill-dep-map/src/scoped_dependency_map.rs
T
Ruben FiszelandClaude Opus 4.7 e636f589a5 fix: prevent flow-dep job stalls under row-lock contention (#8952)
* refactor: split flow-dep job tx so subprocesses don't hold row locks

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

* feat: link flow version from run page to pinned flow viewer

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

* fix: time-out dep job phase 1/3 db ops and surface error on flow page

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

* fix: dissolve dep_map in phase 1 and recheck flow version unconditionally

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

* fix: address PR review — view-latest reload, decimal truncation, app version

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

* fix: keep dissolve in phase 3 for relative-import dep jobs

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

* chore: trim verbose comments and refresh sqlx offline cache

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

* fix: address cubic — propagate dissolve errors, include workspace in reload key

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

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-27 16:03:57 +00:00

613 lines
22 KiB
Rust

use serde::Serialize;
use sqlx::PgExecutor;
use tokio::sync::RwLock;
use windmill_common::{
apps::traverse_app_inline_scripts,
cache,
error::{Error, Result},
flows::{FlowModuleValue, FlowValue},
scripts::ScriptLang,
};
use std::collections::HashSet;
lazy_static::lazy_static! {
pub static ref WMDEBUG_NO_DMAP_DISSOLVE: bool = std::env::var("WMDEBUG_NO_DMAP_DISSOLVE").is_ok();
}
type PathString = String;
type LockHash = i64;
#[derive(Serialize)]
pub struct DependencyMap {
pub workspace_id: Option<String>,
pub importer_path: Option<String>,
pub importer_kind: Option<String>,
pub imported_path: Option<String>,
pub importer_node_id: Option<String>,
}
#[derive(Debug, Clone, Serialize, sqlx::FromRow)]
pub struct DependencyDependent {
pub importer_path: String,
pub importer_kind: String,
pub importer_node_ids: Option<Vec<String>>,
}
#[derive(Debug)]
pub struct ScopedDependencyMap {
/// (importer_node_id, imported_path, imported_lockfile_hash)
to_delete: HashSet<(String, PathString, Option<LockHash>)>,
w_id: String,
importer_path: String,
importer_kind: String,
}
impl ScopedDependencyMap {
/// Calls DB, however is assumed to be called once per dependency job
/// AND is scoped to smaller subset of data
/// So it is not too expensive
pub async fn fetch_maybe_rearranged<'a>(
w_id: &str,
importer_path: &str,
importer_kind: &str,
parent_path: &Option<String>,
executor: impl sqlx::Executor<'a, Database = sqlx::Postgres>,
) -> Result<Self> {
if parent_path
.as_ref()
.is_some_and(|x| !x.is_empty() && x != importer_path)
{
tracing::info!(
workspace_id = %w_id,
"detected top level rename from: {} to: {importer_path} on object of kind: {importer_kind}. reflecting in dependency_map.",
parent_path.clone().unwrap_or_default(),
);
let dmap = sqlx::query_as::<_, (String, String, Option<i64>)>(
"
UPDATE dependency_map
SET importer_path = $1
WHERE importer_path = $2
AND importer_kind = $3::text::IMPORTER_KIND
AND workspace_id = $4
RETURNING importer_node_id, imported_path, imported_lockfile_hash
",
)
.bind(importer_path)
.bind(parent_path.clone().unwrap())
.bind(importer_kind)
.bind(w_id)
.fetch_all(executor)
.await?;
Ok(Self {
to_delete: HashSet::from_iter(dmap.into_iter()),
w_id: w_id.to_owned(),
importer_path: importer_path.to_owned(),
importer_kind: importer_kind.to_owned(),
})
} else {
Self::fetch(w_id, importer_path, importer_kind, executor).await
}
}
/// Almost same as [[Self::fetch_maybe_rearranged]], however only reads values, thus a bit faster.
pub async fn fetch<'a>(
w_id: &str,
importer_path: &str,
importer_kind: &str,
executor: impl sqlx::Executor<'a, Database = sqlx::Postgres>,
) -> Result<Self> {
let dmap = sqlx::query_as::<_, (String, String, Option<i64>)>(
"
SELECT importer_node_id, imported_path, imported_lockfile_hash
FROM dependency_map
WHERE workspace_id = $1
AND importer_path = $2
AND importer_kind = $3::text::IMPORTER_KIND",
)
.bind(w_id)
.bind(importer_path)
.bind(importer_kind)
.fetch_all(executor)
.await?;
Ok(Self {
to_delete: HashSet::from_iter(dmap.into_iter()),
w_id: w_id.to_owned(),
importer_path: importer_path.to_owned(),
importer_kind: importer_kind.to_owned(),
})
}
/// Add missing entries to `dependency_map`
/// Remove matching entries
pub async fn patch<'c>(
&mut self,
referenced_paths: Option<Vec<String>>,
node_id: String, // Flow Step/Node ID
mut tx: sqlx::Transaction<'c, sqlx::Postgres>,
) -> Result<sqlx::Transaction<'c, sqlx::Postgres>> {
self.patch_tx_ref(referenced_paths, &node_id, &mut tx)
.await?;
Ok(tx)
}
pub async fn patch_tx_ref<'c>(
&mut self,
// NOTE: Referenced_paths should include all of the paths.
referenced_paths: Option<Vec<String>>,
node_id: &str, // Flow Step/Node ID
tx: &mut sqlx::Transaction<'c, sqlx::Postgres>,
) -> Result<()> {
let Some(mut referenced_paths) = referenced_paths else {
tracing::info!("relative imports are not found for: importer - {}, importer_node_id - {}, importer_kind - {}",
&self.importer_path,
&node_id,
&self.importer_kind,
);
return Ok(());
};
// Fetch lock hashes for all referenced paths
let lock_hashes: std::collections::HashMap<String, Option<i64>> = sqlx::query_as(
"SELECT path, lockfile_hash FROM lock_hash WHERE workspace_id = $1 AND path = ANY($2)",
)
.bind(&self.w_id)
.bind(&referenced_paths)
.fetch_all(&mut **tx)
.await?
.into_iter()
.collect();
referenced_paths.retain(|path| {
let hash = lock_hashes.get(path).cloned().flatten();
!self
.to_delete
.remove(&(node_id.to_owned(), path.clone(), hash))
});
if !referenced_paths.is_empty() {
tracing::info!("adding missing entries to dependency_map: importer_node_id - {}, importer_kind - {}, new_imported_paths - {:?}",
&node_id,
&self.importer_kind,
&lock_hashes,
);
}
for import in referenced_paths {
let lock_hash = lock_hashes.get(&import).copied().flatten();
sqlx::query!(
"INSERT INTO dependency_map (workspace_id, importer_path, importer_kind, imported_path, importer_node_id, imported_lockfile_hash)
VALUES ($1, $2, $3::text::IMPORTER_KIND, $4, $5, $6)
ON CONFLICT (workspace_id, importer_node_id, importer_kind, importer_path, imported_path)
DO UPDATE SET imported_lockfile_hash = EXCLUDED.imported_lockfile_hash",
&self.w_id,
&self.importer_path,
&self.importer_kind,
&import,
node_id,
lock_hash
)
.execute(&mut **tx)
.await?;
tracing::info!("added entry to dependency_map: {import:?}");
}
Ok(())
}
/// Like `patch_tx_ref` but auto-commits each statement on the pool so the
/// caller doesn't have to hold a long-lived tx around a slow subprocess.
pub async fn patch_via_pool(
&mut self,
referenced_paths: Option<Vec<String>>,
node_id: &str,
db: &sqlx::Pool<sqlx::Postgres>,
) -> Result<()> {
let Some(mut referenced_paths) = referenced_paths else {
tracing::info!("relative imports are not found for: importer - {}, importer_node_id - {}, importer_kind - {}",
&self.importer_path,
&node_id,
&self.importer_kind,
);
return Ok(());
};
let lock_hashes: std::collections::HashMap<String, Option<i64>> = sqlx::query_as(
"SELECT path, lockfile_hash FROM lock_hash WHERE workspace_id = $1 AND path = ANY($2)",
)
.bind(&self.w_id)
.bind(&referenced_paths)
.fetch_all(db)
.await?
.into_iter()
.collect();
referenced_paths.retain(|path| {
let hash = lock_hashes.get(path).cloned().flatten();
!self
.to_delete
.remove(&(node_id.to_owned(), path.clone(), hash))
});
if !referenced_paths.is_empty() {
tracing::info!("adding missing entries to dependency_map: importer_node_id - {}, importer_kind - {}, new_imported_paths - {:?}",
&node_id,
&self.importer_kind,
&lock_hashes,
);
}
for import in referenced_paths {
let lock_hash = lock_hashes.get(&import).copied().flatten();
sqlx::query!(
"INSERT INTO dependency_map (workspace_id, importer_path, importer_kind, imported_path, importer_node_id, imported_lockfile_hash)
VALUES ($1, $2, $3::text::IMPORTER_KIND, $4, $5, $6)
ON CONFLICT (workspace_id, importer_node_id, importer_kind, importer_path, imported_path)
DO UPDATE SET imported_lockfile_hash = EXCLUDED.imported_lockfile_hash",
&self.w_id,
&self.importer_path,
&self.importer_kind,
&import,
node_id,
lock_hash
)
.execute(db)
.await?;
tracing::info!("added entry to dependency_map: {import:?}");
}
Ok(())
}
/// Like `dissolve` but borrows `&mut self` so the SDM stays usable.
/// Unlike `dissolve`, propagates DELETE errors instead of swallowing them
/// (callers running this in a phase 1 tx need the failure to surface so
/// they can roll back instead of committing a partial cleanup).
pub async fn dissolve_in_place<'a>(
&mut self,
tx: &mut sqlx::Transaction<'a, sqlx::Postgres>,
) -> Result<()> {
if *WMDEBUG_NO_DMAP_DISSOLVE {
tracing::warn!(
"WMDEBUG_NO_DMAP_DISSOLVE usually should not be used. Behavior might be unstable."
);
self.to_delete.clear();
return Ok(());
}
tracing::info!(
"dissolving dependency_map (in place): importer_path={}, importer_kind={}, count={}",
&self.importer_path,
&self.importer_kind,
self.to_delete.len()
);
let to_delete = std::mem::take(&mut self.to_delete);
for (importer_node_id, imported_path, imported_lockfile_hash) in to_delete {
tracing::info!("cleaning orphan entry from dependency_map: importer_kind - {}, imported_path - {}, importer_node_id - {}, imported_lockfile_hash - {:?}",
&self.importer_kind,
&imported_path,
&importer_node_id,
&imported_lockfile_hash,
);
sqlx::query!(
"
DELETE FROM dependency_map
WHERE workspace_id = $1
AND importer_path = $2
AND importer_kind = $3::text::IMPORTER_KIND
AND importer_node_id = $4
AND imported_path = $5
AND imported_lockfile_hash IS NOT DISTINCT FROM $6
",
&self.w_id,
&self.importer_path,
&self.importer_kind,
&importer_node_id,
&imported_path,
imported_lockfile_hash
)
.execute(&mut **tx)
.await?;
}
Ok(())
}
/// clean orphan entries from `dependency_map`
pub async fn dissolve<'a>(
self,
mut tx: sqlx::Transaction<'a, sqlx::Postgres>,
) -> sqlx::Transaction<'a, sqlx::Postgres> {
if *WMDEBUG_NO_DMAP_DISSOLVE {
tracing::warn!(
"WMDEBUG_NO_DMAP_DISSOLVE usually should not be used. Behavior might be unstable."
);
return tx;
}
tracing::info!("dissolving dependency_map: {:?}", &self);
for (importer_node_id, imported_path, imported_lockfile_hash) in self.to_delete.into_iter()
{
tracing::info!("cleaning orphan entry from dependency_map: importer_kind - {}, imported_path - {}, importer_node_id - {}, imported_lockfile_hash - {:?}",
&self.importer_kind,
&imported_path,
&importer_node_id,
&imported_lockfile_hash,
);
if let Err(err) = sqlx::query!(
"
DELETE FROM dependency_map
WHERE workspace_id = $1
AND importer_path = $2
AND importer_kind = $3::text::IMPORTER_KIND
AND importer_node_id = $4
AND imported_path = $5
AND imported_lockfile_hash IS NOT DISTINCT FROM $6 -- we don't want to delete other entries with other lockfile hash.
",
&self.w_id,
&self.importer_path,
&self.importer_kind,
&importer_node_id,
&imported_path,
imported_lockfile_hash
)
.execute(&mut *tx)
.await
{
tracing::error!(
"error while cleaning dependency_map for: importer_node_id - {}, imported_path - {}, importer_path - {}: {err}",
importer_node_id,
imported_path,
self.importer_path,
);
}
}
tx
}
/// Selectively clean dependency_map for object
/// If `importer_node_id` is None will clear all nodes.
pub async fn clear_map_for_item<'c>(
item_path: &str,
w_id: &str,
importer_kind: &str,
mut tx: sqlx::Transaction<'c, sqlx::Postgres>,
importer_node_id: &Option<String>,
) -> sqlx::Transaction<'c, sqlx::Postgres> {
tracing::warn!(
importer = item_path,
kind = importer_kind,
node_id = importer_node_id,
workspace_id = w_id,
"discovered orphan entry in `dependency_map`. It will be healed automatically, however please report this issue to Windmill Team. It is also advised to rebuild maps in workspace settings in troubleshooting.",
);
if let Err(err) = sqlx::query!(
"DELETE FROM dependency_map
WHERE importer_path = $1 AND importer_kind = $3::text::IMPORTER_KIND
AND workspace_id = $2 AND ($4::text IS NULL OR importer_node_id = $4::text)",
item_path,
w_id,
importer_kind,
importer_node_id.clone(),
)
.execute(&mut *tx)
.await
{
tracing::error!(
workspace_id = w_id,
"error while clearing discovered orphan: {err}"
);
}
tx
}
pub async fn rebuild_map_unchecked(
w_id: &str,
db: &sqlx::Pool<sqlx::Postgres>,
) -> Result<String> {
// Scripts
tracing::info!(workspace_id = w_id, "Rebuilding dependency map for scripts");
for r in sqlx::query!(
r#"SELECT path, hash, language AS "language: ScriptLang" FROM script WHERE workspace_id = $1 AND archived = false AND deleted = false"#,
w_id
)
.fetch_all(db)
.await?
{
let (sd, smd) = cache::script::fetch(&db.clone().into(), r.hash.into()).await?;
let mut dmap = ScopedDependencyMap::fetch(w_id, &r.path, "script", db).await?;
let mut tx = db.begin().await?;
tx = dmap
.patch(
crate::extract_referenced_paths(&sd.code, &r.path, smd.language),
"".into(),
tx,
)
.await?;
if !*WMDEBUG_NO_DMAP_DISSOLVE {
dmap.dissolve(tx).await.commit().await?;
}
tracing::info!(workspace_id = w_id, "Rebuilt for script {}", &r.path);
}
// Fetch only top level versions and paths
tracing::info!(workspace_id = w_id, "Rebuilding dependency map for flows");
for r in sqlx::query!("SELECT path, versions[array_upper(versions, 1)] as version FROM flow WHERE workspace_id = $1 AND archived = false", w_id).fetch_all(db).await? {
if let Some(version) = r.version {
let flow_data = cache::flow::fetch_version(&db.clone().into(), version).await?;
let mut dmap = ScopedDependencyMap::fetch(w_id, &r.path, "flow", db).await?;
let mut tx = db.begin().await?;
let mut to_process = vec![];
let flow_value = flow_data.value();
let mut modules_to_check = flow_value.modules.iter().collect::<Vec<_>>();
if let Some(failure_module) = flow_value.failure_module.as_ref() {
modules_to_check.push(failure_module.as_ref());
}
if let Some(preprocessor_module) = flow_value.preprocessor_module.as_ref() {
modules_to_check.push(preprocessor_module.as_ref());
}
FlowValue::traverse_leafs(modules_to_check, &mut |fmv, id| {
match fmv {
FlowModuleValue::RawScript { content, language, .. } => {
to_process.push((
crate::extract_referenced_paths(
content,
&(r.path.clone() + "/flow"),
Some(*language),
),
id.clone(),
));
}
FlowModuleValue::FlowScript { .. } => {
return Err(Error::internal_err("FlowScript is not supposed to be in flow.").into());
}
_ => {}
}
Ok(())
})?;
for (rp, id) in to_process {
tx = dmap.patch(rp, id, tx).await?;
}
if !*WMDEBUG_NO_DMAP_DISSOLVE {
dmap.dissolve(tx).await.commit().await?;
}
tracing::info!(workspace_id = w_id, "Rebuilt for flow {}", &r.path);
} else {
tracing::error!(workspace_id = w_id, "version is never supposed to be none. skipping flow.");
return Err(Error::internal_err("version was none"));
}
}
// Apps
tracing::info!(workspace_id = w_id, "Rebuilding dependency map for apps");
for r in sqlx::query!("SELECT path, versions[array_upper(versions, 1)] as version FROM app WHERE workspace_id = $1", w_id).fetch_all(db).await? {
if let Some(version) = r.version {
let value = sqlx::query_scalar!(
"SELECT value FROM app_version WHERE id = $1 LIMIT 1",
version
)
.fetch_one(db)
.await?;
let mut dmap = ScopedDependencyMap::fetch(w_id, &r.path, "app", db).await?;
let mut tx = db.begin().await?;
let mut to_process = vec![];
traverse_app_inline_scripts(&value, None, &mut |ais, id| {
to_process.push((
crate::extract_referenced_paths(
&ais.content,
&(r.path.clone() + "/app"),
ais.language,
),
id,
));
Ok(())
})?;
for (ri, id) in to_process {
tx = dmap.patch(ri, id.unwrap_or_default(), tx).await?;
}
if !*WMDEBUG_NO_DMAP_DISSOLVE {
dmap.dissolve(tx).await.commit().await?;
}
tracing::info!(workspace_id = w_id, "Rebuilt for app {}", &r.path);
} else {
tracing::error!(
workspace_id = w_id,
"version is never supposed to be none. skipping app."
);
return Err(Error::internal_err("version was none"));
}
}
Ok("Success".into())
}
/// Run if you want to rebuild maps on specific workspace.
/// Potentially takes much time
pub async fn rebuild_map(w_id: &str, db: &sqlx::Pool<sqlx::Postgres>) -> Result<String> {
lazy_static::lazy_static! {
pub static ref LOCKED: RwLock<bool> = RwLock::new(false);
}
if *LOCKED.read().await {
tracing::warn!(
workspace_id = w_id,
"Tried to rebuild dependency map. However rebuild is already in progress."
);
Ok("There is already one task pending, try again later.".into())
} else {
tracing::info!(workspace_id = w_id, "Rebuilding dependency map");
if *WMDEBUG_NO_DMAP_DISSOLVE {
tracing::warn!("WMDEBUG_NO_DMAP_DISSOLVE usually should not be used. Behavior might be unstable. Please contact Windmill Team for support.")
}
*LOCKED.write().await = true;
let r = Self::rebuild_map_unchecked(w_id, db).await;
*LOCKED.write().await = false;
r
}
}
/// Get imports of a given importer path - returns paths that the importer depends on
pub async fn get_imports<'c>(
importer_path: &str,
workspace_id: &str,
e: impl PgExecutor<'c>,
) -> Result<Vec<String>> {
sqlx::query_scalar!(
r#"
SELECT DISTINCT imported_path as "imported_path!"
FROM dependency_map
WHERE workspace_id = $1
AND importer_path = $2
AND imported_path NOT LIKE 'dependencies/%'
"#,
workspace_id,
importer_path
)
.fetch_all(e)
.await
.map_err(Error::from)
}
pub async fn get_dependents<'c>(
imported_path: &str,
workspace_id: &str,
e: impl PgExecutor<'c>,
) -> Result<Vec<DependencyDependent>> {
sqlx::query_as!(
DependencyDependent,
r#"
SELECT
importer_path,
importer_kind::text as "importer_kind!",
array_agg(importer_node_id) as importer_node_ids
FROM dependency_map
WHERE workspace_id = $1 AND imported_path = $2
GROUP BY importer_path, importer_kind
"#,
workspace_id,
imported_path
)
.fetch_all(e)
.await
.map_err(Error::from)
}
}