Files
windmill/backend/windmill-worker/src/scoped_dependency_map.rs
Pyra d38c96db36 feat: workspace dependencies (#7124)
* commit raw requirements

Signed-off-by: pyranota <pyra@duck.com>

* raw requirements

Signed-off-by: pyranota <pyra@duck.com>

* implement `parse_annotation`

Signed-off-by: pyranota <pyra@duck.com>

* more progress on wdeps

Signed-off-by: pyranota <pyra@duck.com>

* more progress

Signed-off-by: pyranota <pyra@duck.com>

* fixes

Signed-off-by: pyranota <pyra@duck.com>

* more progress

Signed-off-by: pyranota <pyra@duck.com>

* fixes

Signed-off-by: pyranota <pyra@duck.com>

* cli improvements + raw deps

Signed-off-by: pyranota <pyra@duck.com>

* cleanup

Signed-off-by: pyranota <pyra@duck.com>

* fix python versions

Signed-off-by: pyranota <pyra@duck.com>

* progress

Signed-off-by: pyranota <pyra@duck.com>

* update :)

Signed-off-by: pyranota <pyra@duck.com>

* add MaybeLock

Signed-off-by: pyranota <pyra@duck.com>

* go WIP

Signed-off-by: pyranota <pyra@duck.com>

* fix python ignoring py version from requirements

Signed-off-by: pyranota <pyra@duck.com>

* optimize php

Signed-off-by: pyranota <pyra@duck.com>

* require admin to alter

Signed-off-by: pyranota <pyra@duck.com>

* fix(cli): flow generateLocks raw deps

Signed-off-by: pyranota <pyra@duck.com>

* progress in checklist

Signed-off-by: pyranota <pyra@duck.com>

* fix agent workers

Signed-off-by: pyranota <pyra@duck.com>

* nits

Signed-off-by: pyranota <pyra@duck.com>

* nits

Signed-off-by: pyranota <pyra@duck.com>

* nit: remove default features

Signed-off-by: pyranota <pyra@duck.com>

* oh-wow

Signed-off-by: pyranota <pyra@duck.com>

* remove dbg!

Signed-off-by: pyranota <pyra@duck.com>

* nits

Signed-off-by: pyranota <pyra@duck.com>

* add indexes

Signed-off-by: pyranota <pyra@duck.com>

* cleanup

Signed-off-by: pyranota <pyra@duck.com>

* nits

Signed-off-by: pyranota <pyra@duck.com>

* remove todos

Signed-off-by: pyranota <pyra@duck.com>

* fix cli

Signed-off-by: pyranota <pyra@duck.com>

* add debug flag

Signed-off-by: pyranota <pyra@duck.com>

* cli: remove noise

Signed-off-by: pyranota <pyra@duck.com>

* fix cli

Signed-off-by: pyranota <pyra@duck.com>

* remove todos

Signed-off-by: pyranota <pyra@duck.com>

* trigger deps correctly

Signed-off-by: pyranota <pyra@duck.com>

* fix frontend

Signed-off-by: pyranota <pyra@duck.com>

* fix frontend again

Signed-off-by: pyranota <pyra@duck.com>

* finally fix frontend

Signed-off-by: pyranota <pyra@duck.com>

* ee repo ref

Signed-off-by: pyranota <pyra@duck.com>

* fix all

Signed-off-by: pyranota <pyra@duck.com>

* more fixes...

Signed-off-by: pyranota <pyra@duck.com>

* remove test

Signed-off-by: pyranota <pyra@duck.com>

* Update backend-test.yml

* comment out legacy test

Signed-off-by: pyranota <pyra@duck.com>

* fix ci

Signed-off-by: pyranota <pyra@duck.com>

* fix ci?

Signed-off-by: pyranota <pyra@duck.com>

* comment out thing

Signed-off-by: pyranota <pyra@duck.com>

* ignore test

Signed-off-by: pyranota <pyra@duck.com>

* ci

Signed-off-by: pyranota <pyra@duck.com>

* base fixture

Signed-off-by: pyranota <pyra@duck.com>

* fix regression

Signed-off-by: pyranota <pyra@duck.com>

* fix docs links

Signed-off-by: pyranota <pyra@duck.com>

* update min version

Signed-off-by: pyranota <pyra@duck.com>

* simplify

* implement cache for get_latest

Signed-off-by: pyranota <pyra@duck.com>

* move to workspace settings

Signed-off-by: pyranota <pyra@duck.com>

* sqlx + migration

Signed-off-by: pyranota <pyra@duck.com>

* more migrations

Signed-off-by: pyranota <pyra@duck.com>

* use box pin

Signed-off-by: pyranota <pyra@duck.com>

* nit

Signed-off-by: pyranota <pyra@duck.com>

---------

Signed-off-by: pyranota <pyra@duck.com>
Co-authored-by: Ruben Fiszel <ruben@windmill.dev>
2025-11-28 16:00:11 +00:00

478 lines
18 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;
use crate::worker_lockfiles::extract_referenced_paths;
// TODO: To be removed in future versions
lazy_static::lazy_static! {
pub static ref WMDEBUG_NO_DMAP_DISSOLVE: bool = std::env::var("WMDEBUG_NO_DMAP_DISSOLVE").is_ok();
}
// TODO: Rename to DependencyRelation
#[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)
to_delete: HashSet<(String, String)>,
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(crate) 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)>(
"
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
",
)
.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)>(
"
SELECT importer_node_id, imported_path
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(crate) 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(crate) 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(());
};
// This does:
// 1. remove all relative imports from relative_imports that ARE tracked in dependency_map
// 2. remove corresponding trackers from dependency_map
//
// After this operation `relative_imports` variable has only untracked imports.
// We will handle those in the next expression.
//
// After all `reduce`'s called ScopedDependencyMap has only extra/orphan imports
// these are going to be clean up by calling [dissolve]
// NOTE: `retain` iterates over vec and remove the ones whose closures returned false.
referenced_paths.retain(|imported_path| {
!self
.to_delete
// As dmap is HashSet, removing is O(1) operation
// thus making entire process very efficient
// NOTE: `remove` returns true if item was removed and false if wasn't.
.remove(&(node_id.to_owned(), imported_path.to_owned()))
});
// As mentioned above, usually this will always be empty.
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,
&referenced_paths,
);
}
for import in referenced_paths {
sqlx::query!(
"INSERT INTO dependency_map (workspace_id, importer_path, importer_kind, imported_path, importer_node_id)
VALUES ($1, $2, $3::text::IMPORTER_KIND, $4, $5) ON CONFLICT DO NOTHING",
&self.w_id,
&self.importer_path,
&self.importer_kind,
import,
node_id
)
.execute(&mut **tx)
.await?;
tracing::info!("added entry to dependency_map: {import:?}");
}
Ok(())
}
/// clean orphan entries from `dependency_map`
pub(crate) 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);
// We _could_ shove it into single query, but this query is rarely called AND let's keep it simple for redability.
for (importer_node_id, imported_path) in self.to_delete.into_iter() {
tracing::info!("cleaning orphan entry from dependency_map: importer_kind - {}, imported_path - {}, importer_node_id - {}",
&self.importer_kind,
&imported_path,
&importer_node_id,
);
// Dissolve MUST succeed. Error in dissolve MUST not block the execution.
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
",
&self.w_id,
&self.importer_path,
&self.importer_kind,
&importer_node_id,
&imported_path,
)
.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.",
);
// MUST succeed. Error MUST not block the execution.
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(crate) async fn rebuild_map_unchecked<'c>(
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(
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
// It is not fetching value
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 {
// To reduce stress on db try to fetch from cache
// Since our flow versions are immutable it is safe to assume if we have cache for specific version/id it is up to date.
let flow_data = cache::flow::fetch_version(&db.clone().into(), version).await?;
// Create map for specific flow
let mut dmap = ScopedDependencyMap::fetch(w_id, &r.path, "flow", db).await?;
// Traverse retrieved flow modules
let mut tx = db.begin().await?;
let mut to_process = vec![];
let mut modules_to_check = flow_data.flow.modules.iter().collect::<Vec<_>>();
if let Some(failure_module) = flow_data.flow.failure_module.as_ref() {
modules_to_check.push(failure_module.as_ref());
}
if let Some(preprocessor_module) = flow_data.flow.preprocessor_module.as_ref() {
modules_to_check.push(preprocessor_module.as_ref());
}
FlowValue::traverse_leafs(modules_to_check, &mut |fmv, id| {
match fmv {
// Since we fetched from flow_version it is safe to assume all inline scripts are in form of RawScript.
FlowModuleValue::RawScript { content, language, .. } => {
to_process.push((
extract_referenced_paths(
content,
&(r.path.clone() + "/flow"),
Some(*language),
),
id.clone(),
));
}
// But just in case we will also handle other cases.
FlowModuleValue::FlowScript { .. } => {
// Abort will cancel transaction.
return Err(Error::internal_err("FlowScript is not supposed to be in flow."));
}
_ => {}
}
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 {
// TODO: Use cache when implemented.
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((
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 dependents of any imported path - returns scripts/flows/apps that depend on it
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!", -- sqlx thinks this is nullable somehow, so enfore with !
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)
}
}