Files
windmill/backend/windmill-common/src/cache.rs
T
2026-03-31 13:48:22 +00:00

1306 lines
44 KiB
Rust

//! Windmill cache system.
//!
//! # Features
//! - `scoped_cache`: Use scoped cache instead of global cache.
//! 1. The cache is made thread-local, so each thread has its own entries.
//! 2. The cache is made temporary, so it is deleted when the program exits.
//! This shall only be used for testing, e.g. [`sqlx::test`] spawn a database per test,
//! and there is only one test per thread, so using thread-local cache avoid unexpected results.
use crate::{
apps::AppScriptId,
error,
flows::{FlowNodeId, FlowValue},
schema::SchemaValidator,
scripts::{ScriptHash, ScriptLang, ScriptModule},
};
use anyhow::anyhow;
#[cfg(feature = "scoped_cache")]
use std::thread::ThreadId;
use std::{
future::Future,
hash::Hash,
panic::Location,
path::{Path, PathBuf},
sync::Arc,
};
use futures::TryFutureExt;
use serde::{Deserialize, Serialize};
use sqlx::{
types::{Json, JsonRawValue as RawValue},
PgExecutor,
};
use uuid::Uuid;
pub use const_format::concatcp;
pub use lazy_static::lazy_static;
pub use quick_cache::sync::Cache;
#[cfg(not(feature = "scoped_cache"))]
lazy_static! {
/// Cache directory for windmill server/worker(s).
/// Lives under `WINDMILL_DIR` (default `/tmp/windmill`) as `cache_db/`.
/// If `WINDMILL_CACHE_PREFIX` (or `WEBMUX_BRANCH`) is set, uses `cache_db/{prefix}/`.
pub static ref CACHE_PATH: PathBuf = {
let base = PathBuf::from(&*crate::worker::WINDMILL_DIR).join("cache_db");
let prefix = std::env::var("WINDMILL_CACHE_PREFIX")
.or_else(|_| std::env::var("WEBMUX_BRANCH"))
.unwrap_or_default();
if prefix.is_empty() { base } else { base.join(prefix) }
};
}
#[cfg(feature = "scoped_cache")]
lazy_static! {
/// Temporary directory for thread-local cache.
pub static ref CACHE_PATH_TMP: tempfile::TempDir = {
tempfile::tempdir().expect("Failed to create temporary directory")
};
/// Cache directory for windmill server/worker(s).
pub static ref CACHE_PATH: PathBuf = CACHE_PATH_TMP.as_ref().to_path_buf();
}
/// An item that can be imported/exported from/into the file-system.
pub trait Item: Sized {
/// Returns the path of the item within the given `root` path.
fn path(&self, root: impl AsRef<Path>) -> PathBuf;
}
/// Bytes storage.
pub trait Storage {
/// Get bytes for `item`.
fn get(&self, item: impl Item) -> std::io::Result<Vec<u8>>;
/// Put bytes for `item`.
fn put(&self, item: impl Item, data: impl AsRef<[u8]>) -> std::io::Result<()>;
/// Get utf8 string for `item`.
#[inline(always)]
fn get_utf8(&self, item: impl Item) -> error::Result<String> {
Ok(String::from_utf8(self.get(item)?)?)
}
/// Get json for `item`.
#[inline(always)]
fn get_json<T: for<'de> Deserialize<'de>>(&self, item: impl Item) -> error::Result<T> {
Ok(serde_json::from_slice(&self.get(item)?)?)
}
/// Get json raw value for `item`.
#[inline(always)]
fn get_json_raw(&self, item: impl Item) -> error::Result<Box<RawValue>> {
Ok(RawValue::from_string(self.get_utf8(item)?)?)
}
}
/// A type that can be imported from [`Storage`].
pub trait Import: Sized {
fn import(src: &impl Storage) -> error::Result<Self>;
}
/// A type that can be exported to [`Storage`].
pub trait Export: Sized {
/// The untrusted type that can be imported from [`Storage`].
type Untrusted: Import;
/// Resolve the untrusted type into the trusted type.
fn resolve(src: Self::Untrusted) -> error::Result<Self>;
/// Export the trusted type into storage.
fn export(&self, dst: &impl Storage) -> error::Result<()>;
}
/// A file-system backed concurrent cache.
pub struct FsBackedCache<Key, Val, Root> {
#[cfg(not(feature = "scoped_cache"))]
cache: Cache<Key, Val>,
#[cfg(feature = "scoped_cache")]
cache: Cache<(ThreadId, Key), Val>,
root: Root,
}
impl<Key: Eq + Hash + Item + Clone, Val: Export + Clone, Root: AsRef<Path>>
FsBackedCache<Key, Val, Root>
{
/// Create a new file-system backed cache with `items_capacity` capacity.
/// The cache will be stored in the `root` directory.
pub fn new(root: Root, items_capacity: usize) -> Self {
Self { cache: Cache::new(items_capacity), root }
}
/// Build a path for the given `key`.
pub fn path(&self, key: &Key) -> PathBuf {
#[cfg(feature = "scoped_cache")]
let key = &(std::thread::current().id(), key.clone());
key.path(&self.root)
}
/// Clear the cache.
pub fn clear(&self) {
let _ = std::fs::remove_dir_all(&self.root);
self.cache.clear();
}
/// Remove the item with the given `key` from the cache.
pub fn remove(&self, key: &Key) -> Option<(Key, Val)> {
let _ = std::fs::remove_dir_all(self.path(key));
#[cfg(feature = "scoped_cache")]
let key = &(std::thread::current().id(), key.clone());
let res = self.cache.remove(key);
#[cfg(feature = "scoped_cache")]
let res = res.map(|(k, v)| (k.1, v));
res
}
/// Gets or inserts an item in the cache with key `key`.
pub async fn get_or_insert_async<'a, F>(&'a self, key: Key, with: F) -> error::Result<Val>
where
F: Future<Output = error::Result<Val::Untrusted>>,
{
let import_or_fetch = async {
let path = &self.path(&key);
// Retrieve the data from the cache directory or the database.
if std::fs::metadata(path).is_ok() {
// Cache path exists, read its contents.
match <Val as Export>::Untrusted::import(path).and_then(Val::resolve) {
Ok(data) => return Ok(data),
Err(err) => tracing::warn!(
"Failed to import from file-system, fetch source: {path:?}: {err:?}"
),
}
}
// Cache path doesn't exist or import failed, generate the content.
let data = Val::resolve(with.await?)?;
// Try to export data to the file-system.
// If failed, remove the directory but still return the data.
if let Err(err) = std::fs::create_dir_all(path)
.map_err(Into::into)
.and_then(|_| data.export(&path))
{
tracing::warn!("Failed to export to file-system: {path:?}: {err:?}");
let _ = std::fs::remove_dir_all(path);
}
Ok(data)
};
#[cfg(feature = "scoped_cache")]
let key = (std::thread::current().id(), key.clone());
self.cache.get_or_insert_async(&key, import_or_fetch).await
}
}
/// Like [`lazy_static`]`, but for file-system backed caches.
///
/// # Example
/// ```rust
/// use windmill_common::make_static;
///
/// make_static! {
/// /// String cache with a maximum capacity of 1000 items stored in the
/// /// "subdirectory" directory.
/// static ref CACHE: { u64 => String } in "subdirectory" <= 1000;
/// /// Another cache.
/// static ref ANOTHER_CACHE: { u64 => Vec<String> } in "another" <= 100;
/// }
/// ```
#[macro_export]
macro_rules! make_static {
{ $( $(#[$attr:meta])* static ref $name:ident: { $Key:ty => $Val:ty } in $root:literal <= $cap:literal; )+ } => {
$crate::cache::lazy_static! {
$(
$(#[$attr])*
static ref $name: $crate::cache::FsBackedCache<$Key, $Val, ::std::path::PathBuf> =
$crate::cache::FsBackedCache::new(
$crate::cache::CACHE_PATH.join($root),
$cap
);
)+
}
};
}
// re-export:
pub use make_static;
/// Create an anonymous file-system backed cache for one-time use.
///
/// # Example
/// ```rust
/// use windmill_common::anon;
/// let cache = anon!({ u64 => String } in "subdirectory" <= 1000);
/// ```
#[macro_export]
macro_rules! anon {
({ $Key:ty => $Val:ty } in $root:literal <= $cap:literal) => {{
$crate::cache::make_static! {
static ref __ANON__: { $Key => $Val } in $root <= $cap;
}
&__ANON__
}};
}
// re-export:
pub use anon;
pub mod future {
use super::*;
/// Extension trait for futures that can be cached.
pub trait FutureCachedExt<T: Import>: Future<Output = error::Result<T>> + Sized {
/// Get or insert the future result in the cache.
///
/// # Example
/// ```rust
/// use windmill_common::cache::{self, future::FutureCachedExt};
/// use sqlx::types::Json;
///
/// #[allow(unused)]
/// async {
/// let result = std::future::ready(Ok(42u64))
/// .cached(cache::anon!({ u64 => u64 } in "test" <= 1), 42u64)
/// .await;
///
/// assert_eq!(result.unwrap(), 42u64);
/// };
/// ```
fn cached<
Key: Eq + Hash + Item + Clone,
Val: Export<Untrusted = T> + Clone,
Root: AsRef<Path>,
>(
self,
cache: &FsBackedCache<Key, Val, Root>,
key: Key,
) -> impl Future<Output = error::Result<Val>> {
cache.get_or_insert_async(key.to_owned(), self)
}
}
impl<T: Import, F: Future<Output = error::Result<T>> + Sized> FutureCachedExt<T> for F {}
}
/// Flow data: i.e. a cached `raw_flow`.
/// Contains the original json raw value; [`FlowValue`] is parsed lazily on first access.
pub struct FlowData {
pub raw_flow: Box<RawValue>,
flow: std::sync::OnceLock<FlowValue>,
}
impl std::fmt::Debug for FlowData {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("FlowData")
.field("raw_flow", &"<raw>")
.field("flow", &self.flow.get().map(|_| "<parsed>"))
.finish()
}
}
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct FlowExtras {
pub notes: Option<Box<RawValue>>,
pub groups: Option<Box<RawValue>>,
}
impl FlowData {
pub fn extras(&self) -> Option<FlowExtras> {
serde_json::from_str::<FlowExtras>(self.raw_flow.get())
.map_err(|e| {
tracing::error!("Failed to parse flow extras: {}", e);
error::Error::internal_err(format!("Failed to parse flow extras: {}", e))
})
.ok()
}
}
impl FlowData {
pub fn from_raw(raw_flow: Box<RawValue>) -> error::Result<Self> {
let val = serde_json::from_str::<FlowValue>(raw_flow.get()).map_err(|e| {
error::Error::internal_err(format!("Failed to parse flow value: {}", e))
})?;
let flow = std::sync::OnceLock::new();
let _ = flow.set(val);
Ok(FlowData { raw_flow, flow })
}
/// Return the parsed [`FlowValue`]. Already parsed from `from_raw`.
pub fn value(&self) -> &FlowValue {
self.flow.get_or_init(|| {
serde_json::from_str::<FlowValue>(self.raw_flow.get())
.expect("FlowData raw_flow was validated at construction")
})
}
}
#[derive(Debug)]
pub struct ScriptData {
pub lock: Option<String>,
pub code: String,
pub modules: Option<std::collections::HashMap<String, ScriptModule>>,
}
#[derive(Debug, Clone)]
pub enum RawData {
Flow(Arc<FlowData>),
Script(Arc<ScriptData>),
}
#[derive(Serialize, Deserialize, Debug, Clone)]
pub struct ScriptMetadata {
pub language: Option<ScriptLang>,
pub envs: Option<Vec<String>>,
pub codebase: Option<String>,
pub schema: Option<String>,
pub schema_validator: Option<SchemaValidator>,
}
#[derive(Debug)]
pub struct RawScript {
pub content: String,
pub lock: Option<String>,
pub meta: Option<ScriptMetadata>,
pub modules: Option<std::collections::HashMap<String, ScriptModule>>,
}
#[derive(Debug, Deserialize, Serialize)]
pub struct RawScriptApi {
pub content: String,
pub lock: Option<String>,
pub meta: Option<ScriptMetadata>,
pub modules: Option<std::collections::HashMap<String, ScriptModule>>,
}
impl From<RawScript> for RawScriptApi {
fn from(value: RawScript) -> Self {
RawScriptApi {
content: value.content,
lock: value.lock,
meta: value.meta,
modules: value.modules,
}
}
}
impl From<RawScriptApi> for RawScript {
fn from(value: RawScriptApi) -> Self {
RawScript {
content: value.content,
lock: value.lock,
meta: value.meta,
modules: value.modules,
}
}
}
#[derive(Debug)]
pub struct RawFlow {
pub raw_flow: Box<RawValue>,
}
#[derive(Debug)]
pub struct RawNode {
pub raw_code: Option<String>,
pub raw_lock: Option<String>,
pub raw_flow: Option<Box<RawValue>>,
}
#[derive(Debug, Deserialize, Serialize)]
pub struct RawNodeApi {
pub raw_code: Option<String>,
pub raw_lock: Option<String>,
pub raw_flow: Option<Box<RawValue>>,
}
impl From<RawNode> for RawNodeApi {
fn from(value: RawNode) -> Self {
RawNodeApi { raw_code: value.raw_code, raw_lock: value.raw_lock, raw_flow: value.raw_flow }
}
}
impl From<RawNodeApi> for RawNode {
fn from(value: RawNodeApi) -> Self {
RawNode { raw_code: value.raw_code, raw_lock: value.raw_lock, raw_flow: value.raw_flow }
}
}
#[derive(Debug)]
struct Entry<T>(Arc<T>);
impl<T> Clone for Entry<T> {
fn clone(&self) -> Self {
Entry(Arc::clone(&self.0))
}
}
#[derive(Debug, Clone)]
struct ScriptFull {
pub data: Arc<ScriptData>,
pub meta: Arc<ScriptMetadata>,
}
pub fn unwrap_or_error<Key: std::fmt::Debug, Val>(
at: &'static Location,
entity: &'static str,
key: Key,
) -> impl FnOnce(Option<Val>) -> error::Result<Val> {
move |optional| {
optional
.ok_or_else(|| error::Error::InternalErrAt(at, format!("{key:?}: {entity} not found")))
}
}
/// Clear all caches.
pub fn clear() {
flow::clear();
script::clear();
app::clear();
job::clear();
}
pub mod flow {
use crate::{
worker::{fetch_flow_node_query, Connection},
DB,
};
use super::*;
make_static! {
/// Flow node cache.
/// FIXME: Use `Arc<Node>` for cheap cloning.
static ref NODES: { FlowNodeId => RawData } in "flow_2" <= 1000;
/// Flow version value cache (version id => value).
static ref FLOWS: { i64 => Entry<FlowData> } in "flows_2" <= 1000;
/// Flow version lite value cache (version id => value).
static ref FLOWS_LITE: { i64 => Entry<FlowData> } in "flowslite" <= 1000;
}
/// Clear the flow cache.
pub fn clear() {
NODES.clear();
FLOWS.clear();
FLOWS_LITE.clear();
}
/// Fetch the flow node script referenced by `node` from the cache.
/// If not present, import from the file-system cache or fetch it from the database and write
/// it to the file system and cache.
/// This should be preferred over fetching the database directly.
#[track_caller]
pub fn fetch_script<'c>(
conn: &'c Connection,
node: FlowNodeId,
) -> impl Future<Output = error::Result<Arc<ScriptData>>> + 'c {
let fetch_node = fetch_node(conn, node);
async move {
fetch_node.await.and_then(|data| match data {
RawData::Script(data) => Ok(data),
RawData::Flow(_) => Err(error::Error::internal_err(format!(
"Flow node ({:x}) isn't a script node.",
node.0
))),
})
}
}
/// Fetch the flow node flow value referenced by `node` from the cache.
/// If not present, import from the file-system cache or fetch it from the database and write
/// it to the file system and cache.
/// This should be preferred over fetching the database directly.
#[track_caller]
pub fn fetch_flow<'c>(
db: &'c DB,
node: FlowNodeId,
) -> impl Future<Output = error::Result<Arc<FlowData>>> + 'c {
async move {
let conn = Connection::Sql(db.clone());
let fetch_node = fetch_node(&conn, node);
fetch_node.await.and_then(|data| match data {
RawData::Flow(data) => Ok(data),
RawData::Script(_) => Err(error::Error::internal_err(format!(
"Flow node ({:x}) isn't a flow node.",
node.0
))),
})
}
}
/// Fetch the flow node referenced by `node` from the cache.
/// If not present, import from the file-system cache or fetch it from the database and write
/// it to the file system and cache.
/// This should be preferred over fetching the database directly.
#[track_caller]
pub(super) fn fetch_node<'c>(
conn: &'c Connection,
node: FlowNodeId,
) -> impl Future<Output = error::Result<RawData>> + 'c {
let loc = Location::caller();
// If not present, `get_or_insert_async` will lock the key until the future completes,
// so only one thread will be able to fetch the data from the database and write it to
// the file system and cache, hence no race on the file system.
NODES.get_or_insert_async(node, async move {
match conn {
Connection::Sql(db) => fetch_flow_node_query(db, node.0, loc).await,
Connection::Http(client) => {
let r = client
.get::<RawNodeApi>(&format!("/api/agent_workers/flow_script/{}", node.0))
.await?;
Ok(r.into())
}
}
})
}
#[track_caller]
pub fn fetch_version<'c>(
e: impl PgExecutor<'c>,
id: i64,
) -> impl Future<Output = error::Result<Arc<FlowData>>> {
let loc = Location::caller();
let fut = FLOWS.get_or_insert_async(id, async move {
sqlx::query_scalar!(
"SELECT value AS \"value!: Json<Box<RawValue>>\"
FROM flow_version WHERE id = $1 LIMIT 1",
id,
)
.fetch_optional(e)
.await
.map_err(Into::into)
.and_then(unwrap_or_error(&loc, "Flow version", id))
.map(|Json(raw_flow)| RawFlow { raw_flow })
});
fut.map_ok(|Entry(data)| data)
}
#[track_caller]
pub fn fetch_version_lite<'c>(
e: impl PgExecutor<'c>,
id: i64,
) -> impl Future<Output = error::Result<Arc<FlowData>>> {
let loc = Location::caller();
let fut = FLOWS_LITE.get_or_insert_async(id, async move {
sqlx::query_scalar!(
"SELECT value AS \"value!: Json<Box<RawValue>>\"
FROM flow_version_lite WHERE id = $1 LIMIT 1",
id,
)
.fetch_optional(e)
.await
.map_err(Into::into)
.and_then(unwrap_or_error(&loc, "Flow version \"lite\"", id))
.map(|Json(raw_flow)| RawFlow { raw_flow })
});
fut.map_ok(|Entry(data)| data)
}
}
pub mod script {
use crate::{db::DB, worker::Connection};
use super::*;
make_static! {
/// Scripts cache.
/// FIXME: Use `Arc<Val>` for cheap cloning.
static ref CACHE: { ScriptHash => ScriptFull } in "script_2" <= 1000;
}
/// Clear the script cache.
pub fn clear() {
CACHE.clear();
}
/// Fetch the script referenced by `hash` from the cache.
/// If not present, import from the file-system cache or fetch it from the database and write
/// it to the file system and cache.
/// This should be preferred over fetching the database directly.
#[track_caller]
pub fn fetch(
conn: &Connection,
hash: ScriptHash,
) -> impl Future<Output = error::Result<(Arc<ScriptData>, Arc<ScriptMetadata>)>> {
// If not present, `get_or_insert_async` will lock the key until the future completes,
// so only one thread will be able to fetch the data from the database and write it to
// the file system and cache, hence no race on the file system.
let loc = Location::caller();
let conn = conn.clone();
let fut = CACHE.get_or_insert_async(hash, async move {
match conn {
Connection::Sql(db) => fetch_script_from_db(&db, hash, loc).await,
Connection::Http(client) => {
let r = client
.get::<RawScriptApi>(&format!("/api/agent_workers/script/{}", hash.0))
.await?;
Ok(r.into())
}
}
});
fut.map_ok(|ScriptFull { data, meta }| (data, meta))
}
pub async fn fetch_script_from_db(
db: &DB,
hash: ScriptHash,
loc: &'static Location<'_>,
) -> error::Result<RawScript> {
sqlx::query!(
"SELECT \
content AS \"content!: String\",
lock AS \"lock: String\", \
language AS \"language: Option<ScriptLang>\", \
envs AS \"envs: Vec<String>\", \
schema AS \"schema: String\", \
schema_validation AS \"schema_validation: bool\", \
codebase LIKE '%.tar' as use_tar, \
codebase LIKE '%.esm%' as is_esm, \
modules AS \"modules: serde_json::Value\" \
FROM script WHERE hash = $1 LIMIT 1",
hash.0
)
.fetch_optional(db)
.await
.map_err(Into::into)
.and_then(unwrap_or_error(&loc, "Script", hash))
.and_then(|r| {
Ok(RawScript {
content: r.content,
lock: r.lock,
modules: r.modules.and_then(|v| serde_json::from_value(v).ok()),
meta: Some(ScriptMetadata {
language: r.language,
envs: r.envs,
codebase: if let Some(use_tar) = r.use_tar {
let mut sh = hash.to_string();
if r.is_esm.unwrap_or(false) {
sh = format!("{sh}.esm");
}
if use_tar {
sh = format!("{sh}.tar");
}
Some(sh)
} else {
None
},
schema_validator: if r.schema_validation {
r.schema
.as_ref()
.map(|schema_str| {
SchemaValidator::from_schema(schema_str).map_err(|e| anyhow!("Couldn't create schema validator for script requiring schema validation: {e}"))
})
.transpose()?
} else {
None
},
schema: r.schema,
}),
})
})
}
/// Invalidate the script cache for the given `hash`.
pub fn invalidate(hash: ScriptHash) {
let _ = CACHE.remove(&hash);
}
}
pub mod app {
use crate::worker::{fetch_raw_script_from_app_query, Connection};
use super::*;
make_static! {
/// App scripts cache.
static ref CACHE: { AppScriptId => Entry<ScriptData> } in "app_2" <= 1000;
}
/// Clear the app scripts cache.
pub fn clear() {
CACHE.clear();
}
/// Fetch the app script referenced by `id` from the cache.
/// If not present, import from the file-system cache or fetch it from the database and write
/// it to the file system and cache.
/// This should be preferred over fetching the database directly.
#[track_caller]
pub fn fetch_script<'c>(
conn: &'c Connection,
id: AppScriptId,
) -> impl Future<Output = error::Result<Arc<ScriptData>>> + 'c {
// If not present, `get_or_insert_async` will lock the key until the future completes,
// so only one thread will be able to fetch the data from the database and write it to
// the file system and cache, hence no race on the file system.
let loc = Location::caller();
let fut = CACHE.get_or_insert_async(id, async move {
match conn {
Connection::Sql(db) => fetch_raw_script_from_app_query(db, id.0, loc).await,
Connection::Http(client) => {
let r = client
.get::<RawScriptApi>(&format!("/api/agent_workers/app_script/{}", id.0))
.await?;
Ok(r.into())
}
}
});
fut.map_ok(|Entry(data)| data)
}
}
pub mod job {
use super::*;
use crate::{jobs::JobKind, worker::Connection, DB};
#[cfg(not(feature = "scoped_cache"))]
lazy_static! {
/// Very small in-memory cache for "preview" jobs raw data.
static ref PREVIEWS: Cache<Uuid, RawData> = Cache::new(50);
}
#[cfg(feature = "scoped_cache")]
lazy_static! {
/// Very small in-memory cache for "preview" jobs raw data.
static ref PREVIEWS: Cache<(ThreadId, Uuid), RawData> = Cache::new(50);
}
/// Clear the job cache.
pub fn clear() {
PREVIEWS.clear();
}
#[track_caller]
pub fn fetch_preview_flow<'a>(
db: &'a DB,
job: &'a Uuid,
raw_flow: Option<Json<Box<RawValue>>>,
) -> impl Future<Output = error::Result<Arc<FlowData>>> + 'a {
// Create the Connection first so it lives for the entire scope
async move {
let conn = Connection::from(db);
let fetch_preview = fetch_preview(&conn, job, None, None, raw_flow);
fetch_preview.await.and_then(|data| match data {
RawData::Flow(data) => Ok(data),
RawData::Script(_) => Err(error::Error::internal_err(format!(
"Job ({job}) isn't a flow job."
))),
})
}
}
#[track_caller]
pub fn fetch_preview_script<'a, 'c>(
e: &'a Connection,
job: &'a Uuid,
// original raw values from `queue` or `completed_job` tables:
// kept for backward compatibility.
raw_lock: Option<String>,
raw_code: Option<String>,
) -> impl Future<Output = error::Result<Arc<ScriptData>>> + 'a {
let fetch_preview = fetch_preview(e, job, raw_lock, raw_code, None);
async move {
fetch_preview.await.and_then(|data| match data {
RawData::Script(data) => Ok(data),
RawData::Flow(_) => Err(error::Error::internal_err(format!(
"Job ({job}) isn't a script job."
))),
})
}
}
#[track_caller]
pub fn fetch_preview<'a, 'c>(
e: &'a Connection,
job: &'a Uuid,
// original raw values from `queue` or `completed_job` tables:
// kept for backward compatibility.
raw_lock: Option<String>,
raw_code: Option<String>,
raw_flow: Option<Json<Box<RawValue>>>,
) -> impl Future<Output = error::Result<RawData>> + 'a {
let loc = Location::caller();
let fetch = async move {
match (raw_lock, raw_code, raw_flow) {
(None, None, None) => match e {
Connection::Sql(pool) => sqlx::query!(
"SELECT raw_code, raw_lock, raw_flow AS \"raw_flow: Json<Box<RawValue>>\" \
FROM v2_job WHERE id = $1 LIMIT 1",
job
)
.fetch_optional(pool)
.await
.map_err(Into::into)
.and_then(unwrap_or_error(&loc, "Preview", job))
.map(|r| (r.raw_lock, r.raw_code, r.raw_flow)),
Connection::Http(_) => Err(error::Error::InternalErr(format!(
"Cannot fetch preview in HTTP mode"
))),
},
(lock, code, flow) => Ok((lock, code, flow)),
}
.and_then(|(lock, code, flow)| match flow {
Some(Json(flow)) => FlowData::from_raw(flow).map(Arc::new).map(RawData::Flow),
_ => Ok(RawData::Script(Arc::new(ScriptData {
code: code.unwrap_or_default(),
lock,
modules: None,
}))),
})
};
#[cfg(not(feature = "scoped_cache"))]
return PREVIEWS.get_or_insert_async(job, fetch);
#[cfg(feature = "scoped_cache")]
async move {
let job = &(std::thread::current().id(), job.clone());
PREVIEWS.get_or_insert_async(job, fetch).await
}
}
#[track_caller]
pub fn fetch_script(
db: DB,
kind: &JobKind,
hash: Option<ScriptHash>,
) -> impl Future<Output = error::Result<Arc<ScriptData>>> {
use JobKind::*;
let loc = Location::caller();
let kind = kind.clone();
async move {
match (kind, hash.map(|ScriptHash(id)| id)) {
(FlowScript, Some(id)) => {
flow::fetch_script(&Connection::Sql(db.clone()), FlowNodeId(id)).await
}
(Script | Dependencies, Some(hash)) => script::fetch(&db.into(), ScriptHash(hash))
.await
.map(|(data, _meta)| data),
(AppScript, Some(id)) => {
app::fetch_script(&Connection::Sql(db.clone()), AppScriptId(id)).await
}
_ => Err(error::Error::internal_err(format!(
"Isn't a script job: {:?}",
kind
))),
}
.map_err(error::relocate_internal(loc))
}
}
#[track_caller]
pub fn fetch_flow<'c>(
db: &'c DB,
kind: &JobKind,
hash: Option<ScriptHash>,
) -> impl Future<Output = error::Result<Arc<FlowData>>> + 'c {
use JobKind::*;
let loc = Location::caller();
let kind = kind.clone();
async move {
match (kind, hash.map(|ScriptHash(id)| id)) {
(FlowDependencies, Some(id)) => flow::fetch_version(db, id).await,
(FlowNode, Some(id)) => flow::fetch_flow(db, FlowNodeId(id)).await,
(Flow, Some(id)) | (SingleStepFlow, Some(id)) => {
match flow::fetch_version_lite(db, id).await {
Ok(raw_flow) => Ok(raw_flow),
Err(_) => flow::fetch_version(db, id).await,
}
}
_ => Err(error::Error::internal_err(format!(
"Isn't a flow job {:?}",
kind
))),
}
.map_err(error::relocate_internal(loc))
}
}
}
pub mod workspace_dependencies {
use std::{
future::Future,
time::{Duration, Instant},
};
use crate::{error, workspace_dependencies::WorkspaceDependencies, DB};
make_static! {
/// Workspace Dependencies by id and workspace cache.
static ref WORKSPACE_DEPENDENCIES: { (i64, String) => WorkspaceDependencies } in "workspace_dependencies" <= 1000;
}
lazy_static::lazy_static! {
/// Cache for checking if default/unnamed workspace dependencies exist for a workspace and language.
/// Cache key: (workspace_id, language)
/// Cache value: (exists: bool, cached_at timestamp)
static ref DEFAULT_WD_EXISTS_CACHE: quick_cache::sync::Cache<(String, String), (bool, Instant)> = quick_cache::sync::Cache::new(500);
/// Cache timeout for existence checks (default 10 seconds, configurable via EXISTS_CACHE_TIMEOUT_MS env var)
pub static ref EXISTS_CACHE_TIMEOUT: Duration = Duration::from_millis(
std::env::var("EXISTS_CACHE_TIMEOUT_MS")
.ok()
.and_then(|v| v.parse().ok())
.unwrap_or(10_000)
);
}
pub fn fetch_workspace_dependencies<'c>(
id: i64,
workspace_id: String,
db: &'c DB,
) -> impl Future<Output = error::Result<WorkspaceDependencies>> + 'c {
tracing::debug!(workspace_id = %workspace_id, id, "fetching workspace dependencies");
WORKSPACE_DEPENDENCIES.get_or_insert_async(
(id, workspace_id.clone()),
WorkspaceDependencies::get(id, workspace_id, db),
)
}
pub fn get_cached_is_unnamed_workspace_dependencies_exists<'c>(
dependencies_filename: String,
workspace_id: String,
) -> Option<bool> {
let exists_key = (workspace_id.to_string(), dependencies_filename);
if let Some((exists, cached_at)) = DEFAULT_WD_EXISTS_CACHE.get(&exists_key) {
if cached_at.elapsed() < *EXISTS_CACHE_TIMEOUT {
tracing::debug!(
workspace_id = %workspace_id,
exists,
"cache hit for unnamed workspace dependencies existence"
);
return Some(exists);
} else {
tracing::debug!(
workspace_id = %workspace_id,
"cache expired for unnamed workspace dependencies existence"
);
DEFAULT_WD_EXISTS_CACHE.remove(&exists_key);
}
} else {
tracing::debug!(
workspace_id = %workspace_id,
"cache miss for unnamed workspace dependencies existence"
);
}
None
}
pub fn set_cached_is_unnamed_workspace_dependencies_exists<'c>(
dependencies_filename: String,
workspace_id: String,
exists: bool,
) {
tracing::debug!(
workspace_id = %workspace_id,
exists,
"setting cache for unnamed workspace dependencies existence"
);
let exists_key = (workspace_id.to_string(), dependencies_filename);
DEFAULT_WD_EXISTS_CACHE.insert(exists_key, (exists, Instant::now()));
}
}
/// Temporary raw script content cache for CLI lock generation.
pub mod raw_script_temp {
use super::*;
use crate::DB;
make_static! {
static ref CACHE: { String => String } in "raw_script_temp" <= 10000;
}
/// Compute hash for raw script content (includes workspace_id for isolation).
pub fn compute_hash(workspace_id: &str, content: &str) -> String {
use sha2::{Digest, Sha256};
let mut hasher = Sha256::new();
hasher.update(workspace_id.as_bytes());
hasher.update(content.as_bytes());
format!("{:x}", hasher.finalize())
}
/// Load content from cache, falling back to DB.
pub fn load(hash: String, db: &DB) -> impl Future<Output = error::Result<String>> + '_ {
CACHE.get_or_insert_async(hash.clone(), async move {
sqlx::query_scalar!("SELECT content FROM raw_script_temp WHERE hash = $1", &hash)
.fetch_optional(db)
.await?
.ok_or_else(|| error::Error::NotFound(format!("raw_script_temp hash: {}", hash)))
})
}
}
const _: () = {
impl Import for RawFlow {
fn import(src: &impl Storage) -> error::Result<Self> {
Ok(Self { raw_flow: src.get_json_raw("flow.json")? })
}
}
impl Export for FlowData {
type Untrusted = RawFlow;
fn resolve(src: Self::Untrusted) -> error::Result<Self> {
FlowData::from_raw(src.raw_flow)
}
fn export(&self, dst: &impl Storage) -> error::Result<()> {
Ok(dst.put("flow.json", self.raw_flow.get().as_bytes())?)
}
}
impl Import for RawScript {
fn import(src: &impl Storage) -> error::Result<Self> {
let content = src.get_utf8("code.txt")?;
let lock = src.get_utf8("lock.txt").ok();
let meta = src.get_json("info.json").ok();
Ok(Self { content, lock, meta, modules: None })
}
}
impl Export for ScriptData {
type Untrusted = RawScript;
fn resolve(src: Self::Untrusted) -> error::Result<Self> {
Ok(ScriptData { code: src.content, lock: src.lock, modules: src.modules })
}
fn export(&self, dst: &impl Storage) -> error::Result<()> {
dst.put("code.txt", self.code.as_bytes())?;
if let Some(lock) = self.lock.as_ref() {
dst.put("lock.txt", lock.as_bytes())?;
}
Ok(())
}
}
impl ScriptMetadata {
fn export_metadata(&self, dst: &impl Storage) -> error::Result<()> {
Ok(dst.put("info.json", serde_json::to_vec(self)?)?)
}
}
impl Export for ScriptFull {
type Untrusted = RawScript;
fn resolve(mut src: Self::Untrusted) -> error::Result<Self> {
let Some(meta) = src.meta.take() else {
return Err(error::Error::internal_err("Invalid script src".to_string()));
};
Ok(ScriptFull {
data: Arc::new(ScriptData {
code: src.content,
lock: src.lock,
modules: src.modules,
}),
meta: Arc::new(meta),
})
}
fn export(&self, dst: &impl Storage) -> error::Result<()> {
self.data.export(dst)?;
self.meta.export_metadata(dst)?;
Ok(())
}
}
impl Import for RawNode {
fn import(src: &impl Storage) -> error::Result<Self> {
let code = src.get_utf8("code.txt").ok();
let lock = src.get_utf8("lock.txt").ok();
let flow = src.get_json_raw("flow.json").ok();
Ok(Self { raw_code: code, raw_lock: lock, raw_flow: flow })
}
}
impl Export for RawData {
type Untrusted = RawNode;
fn resolve(src: Self::Untrusted) -> error::Result<Self> {
match src {
RawNode { raw_flow: Some(flow), .. } => {
FlowData::from_raw(flow).map(Arc::new).map(Self::Flow)
}
RawNode { raw_code: Some(code), raw_lock: lock, .. } => {
Ok(Self::Script(Arc::new(ScriptData {
code,
lock,
modules: None,
})))
}
_ => Err(error::Error::internal_err(
"Invalid raw data src".to_string(),
)),
}
}
fn export(&self, dst: &impl Storage) -> error::Result<()> {
match self {
RawData::Flow(data) => data.export(dst),
RawData::Script(data) => data.export(dst),
}
}
}
impl<T: Export> Export for Entry<T> {
type Untrusted = T::Untrusted;
fn resolve(src: Self::Untrusted) -> error::Result<Self> {
Ok(Entry(Arc::new(T::resolve(src)?)))
}
fn export(&self, dst: &impl Storage) -> error::Result<()> {
self.0.export(dst)
}
}
impl<T: for<'de> Deserialize<'de> + Serialize> Import for T {
fn import(src: &impl Storage) -> error::Result<Self> {
let data = src.get("self.json")?;
Ok(serde_json::from_slice(&data)?)
}
}
impl<T: Clone + for<'de> Deserialize<'de> + Serialize> Export for T {
type Untrusted = Self;
fn resolve(src: Self::Untrusted) -> error::Result<Self> {
Ok(src)
}
fn export(&self, dst: &impl Storage) -> error::Result<()> {
Ok(dst.put("self.json", serde_json::to_vec(self)?)?)
}
}
impl<T: AsRef<Path>> Storage for T {
fn get(&self, item: impl Item) -> std::io::Result<Vec<u8>> {
use std::fs::OpenOptions;
use std::io::Read;
OpenOptions::new()
.read(true)
.open(item.path(self))
.and_then(|mut file| {
let mut buf = vec![];
file.read_to_end(&mut buf)?;
Ok(buf)
})
}
fn put(&self, item: impl Item, data: impl AsRef<[u8]>) -> std::io::Result<()> {
use std::fs::OpenOptions;
use std::io::Write;
OpenOptions::new()
.write(true)
.create(true)
.open(item.path(self))
.and_then(|mut file| file.write_all(data.as_ref()))
}
}
macro_rules! impl_item {
($( ($t:ty, |$x:ident| $join:expr) ),*) => {
$(
impl Item for $t {
fn path(&self, root: impl AsRef<Path>) -> PathBuf {
let $x = self;
root.as_ref().join($join)
}
}
)*
};
}
impl_item! {
(&'static str, |x| x),
(i64, |x| format!("{:016x}", *x as u64)),
(u64, |x| format!("{:016x}", x)),
(Uuid, |x| format!("{:032x}", x.as_u128())),
(ScriptHash, |x| format!("{:016x}", x.0)),
((u8, ScriptHash), |x| format!("{:02x}-{:016x}", x.0, x.1.0)),
(FlowNodeId, |x| format!("{:016x}", x.0)),
(AppScriptId, |x| format!("{:016x}", x.0)),
((i64, String), |x| format!("{}-{}", x.1, x.0)),
(String, |x| x.as_str())
}
#[cfg(feature = "scoped_cache")]
impl<T: Item> Item for (ThreadId, T) {
fn path(&self, root: impl AsRef<Path>) -> PathBuf {
let (id, item) = self;
item.path(root.as_ref().join(format!("{id:?}")))
}
}
};
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn flow_data_extras_preserves_notes_and_groups() {
let raw = serde_json::value::to_raw_value(&json!({
"modules": [],
"notes": [{"id": "n1", "text": "hello", "color": "blue", "type": "group",
"contained_node_ids": ["a", "b"], "locked": false}],
"groups": [{"start_id": "a", "end_id": "b", "summary": "grp", "color": "green"}]
}))
.unwrap();
let data = FlowData::from_raw(raw).unwrap();
// FlowValue ignores notes/groups
assert!(data.value().modules.is_empty());
// But extras() recovers them from the raw JSON
let extras = data.extras().expect("extras should parse");
let notes: serde_json::Value =
serde_json::from_str(extras.notes.expect("notes present").get()).unwrap();
assert_eq!(notes.as_array().unwrap().len(), 1);
assert_eq!(notes[0]["id"], "n1");
assert_eq!(notes[0]["color"], "blue");
let groups: serde_json::Value =
serde_json::from_str(extras.groups.expect("groups present").get()).unwrap();
assert_eq!(groups.as_array().unwrap().len(), 1);
assert_eq!(groups[0]["start_id"], "a");
}
#[test]
fn flow_data_extras_returns_none_when_missing() {
let raw = serde_json::value::to_raw_value(&json!({"modules": []})).unwrap();
let data = FlowData::from_raw(raw).unwrap();
let extras = data
.extras()
.expect("extras should parse even without notes/groups");
assert!(extras.notes.is_none());
assert!(extras.groups.is_none());
}
#[test]
fn flow_data_extras_lost_after_flow_value_roundtrip() {
// Demonstrates the bug: serializing through FlowValue drops notes/groups.
// This is the root cause of #8641.
let raw = serde_json::value::to_raw_value(&json!({
"modules": [],
"notes": [{"id": "n1", "text": "t", "color": "blue", "type": "free"}]
}))
.unwrap();
let data = FlowData::from_raw(raw).unwrap();
// Re-serialize through FlowValue (what RunFlowDependenciesRequest does)
let stripped = serde_json::to_string(data.value()).unwrap();
let stripped_raw = RawValue::from_string(stripped).unwrap();
let data2 = FlowData::from_raw(stripped_raw).unwrap();
// Notes are gone after the FlowValue round-trip
let extras = data2.extras().expect("extras should parse");
assert!(
extras.notes.is_none(),
"notes lost after FlowValue round-trip"
);
}
}