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>
This commit is contained in:
@@ -295,6 +295,10 @@ impl Engine {
|
||||
// fan-out is counted across the whole synchronous chain. Held to the end
|
||||
// of the call; its Drop re-zeroes the counter on the outermost exit.
|
||||
let _emit_scope = crate::sdk::emit_budget::EmissionBudgetScope::enter();
|
||||
// §9.4 M6: memoize interceptor chain resolution for this execution tree
|
||||
// (same re-entrancy model as the emission budget); cleared at the
|
||||
// outermost boundary so a pooled thread never reuses a foreign app's cache.
|
||||
let _interceptor_cache = crate::sdk::interceptor::InterceptorCacheScope::enter();
|
||||
let effective_limits = self.limits.with_overrides(&req.sandbox_overrides);
|
||||
let logs: Arc<Mutex<Vec<LogEntry>>> = Arc::new(Mutex::new(Vec::new()));
|
||||
let mut engine = build_engine(effective_limits, Some(logs.clone()));
|
||||
|
||||
@@ -23,10 +23,13 @@
|
||||
//! execution, makes any nested op the interceptor performs bypass interception.
|
||||
|
||||
use std::cell::{Cell, RefCell};
|
||||
use std::collections::HashMap;
|
||||
use std::sync::{Arc, LazyLock};
|
||||
use std::time::Duration;
|
||||
|
||||
use picloud_shared::{InterceptorChain, InterceptorEntry, InterceptorService, ScriptId, SdkCallCx};
|
||||
use picloud_shared::{
|
||||
AppId, InterceptorChain, InterceptorEntry, InterceptorService, ScriptId, SdkCallCx,
|
||||
};
|
||||
use rhai::EvalAltResult;
|
||||
use serde_json::{json, Value as Json};
|
||||
use tokio::runtime::Handle as TokioHandle;
|
||||
@@ -72,6 +75,51 @@ thread_local! {
|
||||
/// `IN_INTERCEPTOR` counter, which suppresses interception of an
|
||||
/// interceptor's OWN nested writes but does not track identity.
|
||||
static VISITED: RefCell<Vec<ScriptId>> = const { RefCell::new(Vec::new()) };
|
||||
|
||||
/// §9.4 M6: per-execution resolve cache. Keyed by `(app_id, service, op)` —
|
||||
/// the chain a `(service, op)` resolves to is constant for one app across an
|
||||
/// execution tree, so the FIRST hooked-eligible op of that kind pays the one
|
||||
/// chain query and every later op reuses it. The dominant win is the
|
||||
/// un-hooked case: an empty chain is cached once, so N `kv::set`s in a script
|
||||
/// with no interceptors issue ONE resolve, not N. Cleared at the outermost
|
||||
/// execution boundary so a pooled thread never serves a stale (or foreign)
|
||||
/// app's cache.
|
||||
static RESOLVE_CACHE: RefCell<HashMap<(AppId, String, String), InterceptorChain>> =
|
||||
RefCell::new(HashMap::new());
|
||||
/// Scope nesting depth for the resolve cache (mirrors the emission budget):
|
||||
/// `0` when no execution is active on this thread.
|
||||
static CACHE_ACTIVE: Cell<u32> = const { Cell::new(0) };
|
||||
}
|
||||
|
||||
/// RAII scope wrapping one `execute_ast` for the §9.4 M6 resolve cache.
|
||||
/// Re-entrancy aware: the OUTERMOST scope on a thread clears the cache on entry
|
||||
/// and on final exit; a nested synchronous re-entry (invoke / interceptor)
|
||||
/// shares the same cache, so a whole execution tree resolves each `(service,
|
||||
/// op)` at most once.
|
||||
pub(crate) struct InterceptorCacheScope;
|
||||
|
||||
impl InterceptorCacheScope {
|
||||
pub(crate) fn enter() -> Self {
|
||||
CACHE_ACTIVE.with(|a| {
|
||||
if a.get() == 0 {
|
||||
RESOLVE_CACHE.with(|c| c.borrow_mut().clear());
|
||||
}
|
||||
a.set(a.get() + 1);
|
||||
});
|
||||
Self
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for InterceptorCacheScope {
|
||||
fn drop(&mut self) {
|
||||
CACHE_ACTIVE.with(|a| {
|
||||
let n = a.get().saturating_sub(1);
|
||||
a.set(n);
|
||||
if n == 0 {
|
||||
RESOLVE_CACHE.with(|c| c.borrow_mut().clear());
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
fn currently_in_interceptor() -> bool {
|
||||
@@ -205,21 +253,29 @@ pub(super) fn run_after(
|
||||
}
|
||||
|
||||
/// Resolve the before+after chain for `(service, op)` — every marker visible on
|
||||
/// the calling app's chain, ordered per phase. An `Err` (no tokio runtime, or a
|
||||
/// backend failure) makes the caller fail closed rather than silently allow.
|
||||
/// the calling app's chain, ordered per phase. Memoized per execution tree
|
||||
/// (§9.4 M6): the FIRST call for a `(app_id, service, op)` pays the chain query;
|
||||
/// later calls reuse the cached result. An `Err` (no tokio runtime, or a backend
|
||||
/// failure) makes the caller fail closed rather than silently allow.
|
||||
fn resolve_chain(
|
||||
ictx: &InterceptorCtx,
|
||||
cx: &Arc<SdkCallCx>,
|
||||
service: &'static str,
|
||||
op: &'static str,
|
||||
) -> Result<InterceptorChain, Box<EvalAltResult>> {
|
||||
let cache_key = (cx.app_id, service.to_string(), op.to_string());
|
||||
if let Some(hit) = RESOLVE_CACHE.with(|c| c.borrow().get(&cache_key).cloned()) {
|
||||
return Ok(hit);
|
||||
}
|
||||
let handle = TokioHandle::try_current()
|
||||
.map_err(|e| runtime_err(&format!("{service} interceptor: no tokio runtime: {e}")))?;
|
||||
let interceptors = ictx.interceptors.clone();
|
||||
let cx = cx.clone();
|
||||
handle
|
||||
.block_on(async move { interceptors.resolve(&cx, service, op).await })
|
||||
.map_err(|e| runtime_err(&format!("{service}::{op} interceptor resolve: {e}")))
|
||||
let resolve_cx = cx.clone();
|
||||
let chain = handle
|
||||
.block_on(async move { interceptors.resolve(&resolve_cx, service, op).await })
|
||||
.map_err(|e| runtime_err(&format!("{service}::{op} interceptor resolve: {e}")))?;
|
||||
RESOLVE_CACHE.with(|c| c.borrow_mut().insert(cache_key, chain.clone()));
|
||||
Ok(chain)
|
||||
}
|
||||
|
||||
/// Build the operation-context payload handed to a hook. `result` is `Some` only
|
||||
|
||||
Reference in New Issue
Block a user