Files
MechaCat02 faf04174c4 perf(interceptors): per-execution resolve cache (§9.4 M6)
Memoizes the interceptor chain resolution per execution tree, keyed by
(app_id, service, op). The FIRST hooked-eligible op pays the one chain query;
every later op reuses it — so N kv::sets in a script with no interceptors now
issue ONE resolve, not N (the dominant, zero-marker case caches an empty chain).
InterceptorCacheScope is an RAII scope entered in execute_ast next to the
emission budget, same re-entrancy model: a synchronous invoke/interceptor
re-entry shares the cache, and it is cleared at the outermost boundary so a
pooled thread never serves a foreign app's cache. Pinned by an executor-core
test: 25 sets → 1 resolve, and a fresh execution → a new resolve.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-16 20:22:11 +02:00

410 lines
14 KiB
Rust

//! `kv::` SDK bridge integration tests — runs a real Rhai engine
//! against an in-memory `KvService` impl. Mirrors how
//! `orchestrator-core::LocalExecutorClient` invokes the engine: under
//! `tokio::task::spawn_blocking` so the bridge's `block_on` has a
//! reachable runtime.
use std::collections::{BTreeMap, HashMap};
use std::sync::Arc;
use async_trait::async_trait;
use picloud_executor_core::{Engine, ExecRequest, InvocationType, Limits};
use picloud_shared::{
AppId, ExecutionId, KvError, KvListPage, KvService, NoopDeadLetterService, NoopDocsService,
NoopEventEmitter, NoopHttpService, NoopModuleSource, RequestId, ScriptId, ScriptSandbox,
SdkCallCx, Services,
};
use serde_json::{json, Value};
use tokio::sync::Mutex;
#[derive(Default)]
struct InMemoryKv {
data: Mutex<HashMap<(AppId, String, String), Value>>,
}
#[async_trait]
impl KvService for InMemoryKv {
async fn get(
&self,
cx: &SdkCallCx,
collection: &str,
key: &str,
) -> Result<Option<Value>, KvError> {
Ok(self
.data
.lock()
.await
.get(&(cx.app_id, collection.to_string(), key.to_string()))
.cloned())
}
async fn set(
&self,
cx: &SdkCallCx,
collection: &str,
key: &str,
value: Value,
) -> Result<(), KvError> {
self.data
.lock()
.await
.insert((cx.app_id, collection.to_string(), key.to_string()), value);
Ok(())
}
async fn set_if(
&self,
cx: &SdkCallCx,
collection: &str,
key: &str,
expected: Option<Value>,
new: Value,
) -> Result<bool, KvError> {
let mut data = self.data.lock().await;
let k = (cx.app_id, collection.to_string(), key.to_string());
let matches = match (&expected, data.get(&k)) {
(None, None) => true,
(Some(exp), Some(cur)) => exp == cur,
_ => false,
};
if matches {
data.insert(k, new);
}
Ok(matches)
}
async fn delete(&self, cx: &SdkCallCx, collection: &str, key: &str) -> Result<bool, KvError> {
Ok(self
.data
.lock()
.await
.remove(&(cx.app_id, collection.to_string(), key.to_string()))
.is_some())
}
async fn has(&self, cx: &SdkCallCx, collection: &str, key: &str) -> Result<bool, KvError> {
Ok(self.data.lock().await.contains_key(&(
cx.app_id,
collection.to_string(),
key.to_string(),
)))
}
async fn list(
&self,
cx: &SdkCallCx,
collection: &str,
cursor: Option<&str>,
limit: u32,
) -> Result<KvListPage, KvError> {
let data = self.data.lock().await;
let mut keys: Vec<String> = data
.iter()
.filter(|((a, c, _), _)| *a == cx.app_id && c == collection)
.map(|((_, _, k), _)| k.clone())
.filter(|k| cursor.is_none_or(|c| k.as_str() > c))
.collect();
keys.sort();
let take = if limit == 0 {
usize::MAX
} else {
limit as usize
};
let next_cursor = if keys.len() > take {
keys.truncate(take);
keys.last().cloned()
} else {
None
};
Ok(KvListPage { keys, next_cursor })
}
}
fn make_engine() -> Arc<Engine> {
let services = Services::new(
Arc::new(InMemoryKv::default()),
Arc::new(NoopDocsService),
Arc::new(NoopDeadLetterService),
Arc::new(NoopEventEmitter),
Arc::new(NoopModuleSource),
Arc::new(NoopHttpService),
Arc::new(picloud_shared::NoopFilesService),
Arc::new(picloud_shared::NoopPubsubService),
Arc::new(picloud_shared::NoopSecretsService),
Arc::new(picloud_shared::NoopEmailService),
Arc::new(picloud_shared::NoopUsersService),
Arc::new(picloud_shared::NoopQueueService),
Arc::new(picloud_shared::NoopInvokeService),
Arc::new(picloud_shared::NoopVarsService),
);
Arc::new(Engine::new(Limits::default(), services))
}
fn baseline_request(app_id: AppId) -> ExecRequest {
let execution_id = ExecutionId::new();
ExecRequest {
execution_id,
request_id: RequestId::new(),
script_id: ScriptId::new(),
script_name: "kv-test".into(),
invocation_type: InvocationType::Http,
path: "/kv-test".into(),
method: String::new(),
headers: BTreeMap::new(),
body: Value::Null,
params: BTreeMap::new(),
query: BTreeMap::new(),
rest: String::new(),
sandbox_overrides: ScriptSandbox::default(),
app_id,
script_owner: None,
principal: None,
trigger_depth: 0,
root_execution_id: execution_id,
is_dead_letter_handler: false,
event: None,
}
}
async fn run_script(engine: Arc<Engine>, src: &str, req: ExecRequest) -> Value {
let src = src.to_string();
tokio::task::spawn_blocking(move || engine.execute(&src, req))
.await
.expect("spawn_blocking should not panic")
.expect("script execution should succeed")
.body
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn kv_set_then_get_round_trip() {
let engine = make_engine();
let app = AppId::new();
let src = r#"
let widgets = kv::collection("widgets");
widgets.set("k1", #{ n: 1 });
widgets.get("k1")
"#;
let body = run_script(engine, src, baseline_request(app)).await;
assert_eq!(body, json!({ "n": 1 }));
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn kv_set_if_compare_and_swap() {
let engine = make_engine();
let app = AppId::new();
// `set_if(key, (), new)` inserts only when absent; `set_if(key, expected,
// new)` swaps only on match. Returns a bool each time.
let src = r#"
let c = kv::collection("counters");
let first = c.set_if("n", (), 1); // absent -> inserts, true
let dup = c.set_if("n", (), 2); // present -> false
let bad = c.set_if("n", 99, 3); // mismatch -> false
let good = c.set_if("n", 1, 3); // match -> swaps, true
#{ first: first, dup: dup, bad: bad, good: good, final: c.get("n") }
"#;
let body = run_script(engine, src, baseline_request(app)).await;
assert_eq!(
body,
json!({ "first": true, "dup": false, "bad": false, "good": true, "final": 3 })
);
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn kv_get_missing_returns_unit() {
let engine = make_engine();
let app = AppId::new();
let src = r#"
let c = kv::collection("widgets");
let v = c.get("nope");
v == ()
"#;
let body = run_script(engine, src, baseline_request(app)).await;
assert_eq!(body, json!(true));
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn kv_has_returns_bool() {
let engine = make_engine();
let app = AppId::new();
let src = r#"
let c = kv::collection("widgets");
let before = c.has("k");
c.set("k", "v");
let after = c.has("k");
#{ before: before, after: after }
"#;
let body = run_script(engine, src, baseline_request(app)).await;
assert_eq!(body, json!({ "before": false, "after": true }));
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn kv_delete_returns_was_present() {
let engine = make_engine();
let app = AppId::new();
let src = r#"
let c = kv::collection("widgets");
let nope = c.delete("missing");
c.set("k", 1);
let yep = c.delete("k");
#{ nope: nope, yep: yep }
"#;
let body = run_script(engine, src, baseline_request(app)).await;
assert_eq!(body, json!({ "nope": false, "yep": true }));
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn kv_empty_collection_name_throws() {
let engine = make_engine();
let app = AppId::new();
let src = r#"kv::collection("")"#;
let req = baseline_request(app);
let err = tokio::task::spawn_blocking(move || engine.execute(src, req))
.await
.unwrap()
.expect_err("empty collection should throw");
assert!(format!("{err:?}").contains("kv::collection"));
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn kv_list_pages_via_cursor() {
let engine = make_engine();
let app = AppId::new();
let src = r#"
let c = kv::collection("widgets");
for i in 0..5 { c.set(`k${i}`, i); }
let p1 = c.list("", 2);
let p2 = c.list(p1.next_cursor, 2);
#{
p1_keys: p1.keys,
p1_cursor: p1.next_cursor,
p2_keys: p2.keys,
}
"#;
let body = run_script(engine, src, baseline_request(app)).await;
let obj = body.as_object().unwrap();
let p1_keys = obj["p1_keys"].as_array().unwrap();
let p2_keys = obj["p2_keys"].as_array().unwrap();
assert_eq!(p1_keys.len(), 2);
assert_eq!(p2_keys.len(), 2);
assert!(obj["p1_cursor"].is_string());
}
/// Cross-app isolation via `cx.app_id` — script with `app_id = A`
/// cannot see entries from `app_id = B`. The kv:: bridge never
/// surfaces `app_id` to the script, so this is enforced purely by the
/// service deriving it from the captured `Arc<SdkCallCx>`.
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn kv_bridge_preserves_cross_app_isolation() {
let engine = make_engine();
let app_a = AppId::new();
let app_b = AppId::new();
let writer = r#"
let c = kv::collection("shared");
c.set("k", "from-a");
"ok"
"#;
let _ = run_script(engine.clone(), writer, baseline_request(app_a)).await;
// App B sees nothing under the same collection/key.
let reader = r#"
let c = kv::collection("shared");
c.get("k")
"#;
let body = run_script(engine.clone(), reader, baseline_request(app_b)).await;
assert_eq!(body, Value::Null, "app B must not see app A's value");
// Positive control — WITHOUT it this test has no teeth. `baseline_request`
// mints a fresh execution_id AND script_id each call, so this second run under
// app_a is a DIFFERENT execution/script than the writer. A bridge that keyed
// storage by execution_id or script_id (instead of app_id) would pass the
// negative above AND every single-execution round-trip, while app-scoped
// persistence was completely gone. This catches that: app A must still read
// back its own value across executions.
let body = run_script(engine, reader, baseline_request(app_a)).await;
assert_eq!(
body,
Value::from("from-a"),
"app A must read back its own value in a later execution — storage is keyed \
by app_id, not execution_id/script_id"
);
}
// --- §9.4 M6: per-execution interceptor resolve cache ---------------------
/// An `InterceptorService` that resolves to an EMPTY chain (nothing hooked) but
/// counts every `resolve` call, so a test can prove the per-execution cache
/// collapses N same-`(service, op)` resolves into one.
#[derive(Default)]
struct CountingInterceptors {
calls: std::sync::atomic::AtomicUsize,
}
#[async_trait]
impl picloud_shared::InterceptorService for CountingInterceptors {
async fn resolve(
&self,
_cx: &SdkCallCx,
_service: &str,
_op: &str,
) -> Result<picloud_shared::InterceptorChain, String> {
self.calls.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
Ok(picloud_shared::InterceptorChain::default())
}
}
fn make_engine_with_interceptors(ic: Arc<CountingInterceptors>) -> Arc<Engine> {
let services = Services::new(
Arc::new(InMemoryKv::default()),
Arc::new(NoopDocsService),
Arc::new(NoopDeadLetterService),
Arc::new(NoopEventEmitter),
Arc::new(NoopModuleSource),
Arc::new(NoopHttpService),
Arc::new(picloud_shared::NoopFilesService),
Arc::new(picloud_shared::NoopPubsubService),
Arc::new(picloud_shared::NoopSecretsService),
Arc::new(picloud_shared::NoopEmailService),
Arc::new(picloud_shared::NoopUsersService),
Arc::new(picloud_shared::NoopQueueService),
Arc::new(picloud_shared::NoopInvokeService),
Arc::new(picloud_shared::NoopVarsService),
)
.with_interceptors(ic);
Arc::new(Engine::new(Limits::default(), services))
}
/// M6: within ONE execution, N `kv::set`s of the same `(service, op)` resolve
/// the interceptor chain at most once (the empty chain is cached), and a SECOND
/// execution starts fresh (the cache is cleared at the outermost boundary).
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn resolve_is_cached_per_execution() {
let ic = Arc::new(CountingInterceptors::default());
let engine = make_engine_with_interceptors(ic.clone());
let app = AppId::new();
// 25 sets in one execution — all the same (kv, set).
let src = r#"
let c = kv::collection("c");
let n = 0;
while n < 25 { c.set("k" + n, n); n += 1; }
"done"
"#;
let body = run_script(engine.clone(), src, baseline_request(app)).await;
assert_eq!(body, Value::from("done"));
assert_eq!(
ic.calls.load(std::sync::atomic::Ordering::SeqCst),
1,
"25 kv::sets in one execution must resolve the (kv, set) chain exactly once"
);
// A second execution resolves again (the cache cleared at the outermost exit).
let body = run_script(engine, src, baseline_request(app)).await;
assert_eq!(body, Value::from("done"));
assert_eq!(
ic.calls.load(std::sync::atomic::Ordering::SeqCst),
2,
"a fresh execution must not reuse the previous execution's resolve cache"
);
}