mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-08-19 08:01:25 +00:00
52a04d210f
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1306 lines
44 KiB
Rust
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"
|
|
);
|
|
}
|
|
}
|