Files
PiCloud/crates/executor-core/src/sdk/mod.rs
MechaCat02 8b62b137c0 fix(executor): cap per-execution durable fan-out width
`trigger_depth` bounds how DEEP a trigger/invoke chain runs, but nothing bounded
how WIDE one execution could fan out. A single anonymous request running
`for i in 0..1_000_000 { invoke_async("w", #{}) }` costs a few Rhai ops plus one
cheap outbox INSERT per iteration, so within the op / wall-clock budget it could
write ~10^5 durable rows — each dispatched as its own execution — flooding the
outbox and dispatcher (a durable-amplification DoS).

Add a per-execution ceiling on DURABLE emissions (`invoke_async`,
`pubsub::publish_durable`, `queue::enqueue`, and the shared-topic/-queue
variants), env `PICLOUD_MAX_EMISSIONS_PER_EXECUTION` (default 1000). It's a
thread-local counter wrapped by a re-entrancy-aware `EmissionBudgetScope` around
every `execute_ast`: the OUTERMOST scope resets it, so a fresh dispatched
handler (a new pooled-thread task) gets a full budget while a synchronous
`invoke()` / interceptor re-entry nested in the same call stack SHARES it (fan-
out counted across the whole synchronous chain). The outermost Drop re-zeroes
the counter so a pooled thread never leaks a count into the next task.

Pinned by an `emit_budget` unit test (outermost resets, nested shares, ceiling
trips); the invoke/queue/pubsub/workflow journeys (small counts) stay green.

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

78 lines
2.6 KiB
Rust

//! SDK plumbing — types and the per-call registration entry point.
//!
//! `executor-core` is responsible for building the per-invocation Rhai
//! engine and wiring stateful services into it. v1.1.0 ships the
//! shapes (`Services` bundle, `SdkCallCx`, `register_all` entry point)
//! but no actual services — subsequent v1.1.x PRs (KV in v1.1.1,
//! docs in v1.1.2, …) extend `register_all` rather than re-threading
//! plumbing through `engine.rs`.
//!
//! Bridge functions (`json_to_dynamic` / `dynamic_to_json`) also live
//! here so service modules can convert values without `engine.rs`
//! being the only home for the conversion logic.
pub mod bridge;
pub mod cx;
pub mod dead_letters;
pub mod docs;
pub mod email;
pub mod emit_budget;
pub mod files;
pub mod http;
pub mod interceptor;
pub mod invoke;
pub mod kv;
pub mod pubsub;
pub mod queue;
pub mod retry;
pub mod secrets;
pub mod stdlib;
pub mod users;
pub mod vars;
pub mod workflow;
pub use bridge::{
dynamic_to_json, dynamic_to_json_capped, json_to_dynamic, JsonSizeError,
MAX_JSON_MATERIALIZE_BYTES,
};
pub use cx::SdkCallCx;
use std::sync::Arc;
use picloud_shared::Services;
use rhai::Engine as RhaiEngine;
use crate::engine::Engine;
use crate::sandbox::Limits;
/// Single hook every v1.1.x stateful service registers into. Called
/// once per invocation, just after `build_engine` constructs the
/// sandboxed Rhai engine and just before script compilation.
///
/// v1.1.9 adds the `limits` + `self_engine` parameters needed by the
/// `invoke` bridge for synchronous re-entry. `self_engine` is `None`
/// in harnesses that didn't call `Engine::set_self_weak` after
/// construction; the invoke bridge surfaces a clear error in that case.
pub fn register_all(
engine: &mut RhaiEngine,
services: &Services,
cx: Arc<SdkCallCx>,
limits: Limits,
self_engine: Option<Arc<Engine>>,
) {
kv::register(engine, services, cx.clone(), limits, self_engine.clone());
docs::register(engine, services, cx.clone());
dead_letters::register(engine, services, cx.clone());
http::register(engine, services, cx.clone());
files::register(engine, services, cx.clone());
pubsub::register(engine, services, cx.clone());
queue::register(engine, services, cx.clone());
retry::register(engine, services, cx.clone());
secrets::register(engine, services, cx.clone());
vars::register(engine, services, cx.clone());
email::register(engine, services, cx.clone());
users::register(engine, services, cx.clone());
workflow::register(engine, services, cx.clone());
invoke::register(engine, services, cx, limits, self_engine);
}