Files
PiCloud/crates/executor-core/src/sdk/queue.rs
MechaCat02 c3baa87415 chore(v1.1.9): clippy clean — workspace -D warnings
Surfaced + fixed during the F3 attestation:

- executor-core/sdk/queue.rs: drop redundant .map(|()| ()) call sites;
  enqueue_blocking discards QueueMessageId via .map(|_id| ()) (intentional)
- executor-core/sdk/retry.rs: hoist use std::collections::hash_map::DefaultHasher
  + use std::hash::Hasher to the top of the file (clippy::items_after_statements);
  replace `as i64` casts with i64::try_from + clear comment about jitter
  bound (clippy::cast_possible_wrap)
- executor-core/sdk/invoke.rs: move _LIMITS_IS_COPY const before #[cfg(test)]
  mod tests (clippy::items_after_test_module)
- manager-core/dispatcher.rs: dispatch_one_queue gains #[allow(too_many_lines)]
  (it's the queue tick's whole logic — split makes it less readable than
  the lint); .map(...).unwrap_or(default) → .map_or(default, ...)
- picloud/lib.rs: Limits { trigger_depth_max, ..Limits::default() } via
  struct-update instead of let-mut-assign (clippy::field_reassign_with_default)
- tests: r#"..."# → r"..." where there are no `"` inside (clippy::needless_raw_string_hashes);
  combine InvokeTarget::Name | InvokeTarget::Path arms with identical bodies
  in sdk_invoke.rs (clippy::match_same_arms)

cargo fmt --all -- --check 2>&1 | tail -3: no output (exit 0)
cargo clippy --workspace --all-targets --all-features -- -D warnings:
  Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.41s
Workspace lib smoke: 432 passing (306 manager-core + 74 executor-core + 34 shared + 18 orchestrator-core).
DB-gated E2E (against docker compose postgres on localhost:15432):
  queue_e2e: 4 ok / invoke_e2e: 4 ok / retry_e2e: 3 ok / migration_queue_messages: 4 ok / schema_snapshot: 1 ok

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

269 lines
9.0 KiB
Rust

//! `queue::` Rhai bridge — durable per-app queue producer + inspection
//! (v1.1.9).
//!
//! ```rhai
//! queue::enqueue("payments.process", #{ order_id: 123, amount: 4999 });
//! queue::enqueue("payments.process", msg, #{ delay_ms: 60000, max_attempts: 5 });
//! let total = queue::depth("payments.process");
//! let pending = queue::depth_pending("payments.process");
//! ```
//!
//! No handle pattern (queues are the grouping unit, mirroring pubsub).
//! No `peek` / `dequeue` / `purge` script-side surface — consumers are
//! `queue:receive` triggers; exposing manual dequeue would force the
//! platform to surface visibility-timeout semantics into script-land.
//!
//! `app_id` is derived from `cx.app_id` inside the service — it never
//! appears in the script-side signature.
use std::sync::Arc;
use base64::engine::general_purpose::STANDARD;
use base64::Engine as _;
use picloud_shared::{EnqueueOpts, QueueError, QueueService, SdkCallCx, Services};
use rhai::{Array, Dynamic, Engine as RhaiEngine, EvalAltResult, Map, Module};
use serde_json::Value as Json;
use tokio::runtime::Handle as TokioHandle;
pub(super) fn register(engine: &mut RhaiEngine, services: &Services, cx: Arc<SdkCallCx>) {
let svc = services.queue.clone();
let mut module = Module::new();
// `queue::enqueue(name, message)` — default opts.
{
let svc = svc.clone();
let cx = cx.clone();
module.set_native_fn(
"enqueue",
move |name: &str, message: Dynamic| -> Result<(), Box<EvalAltResult>> {
let json = message_to_json(&message)?;
enqueue_blocking(&svc, &cx, name, json, EnqueueOpts::default())
},
);
}
// `queue::enqueue(name, message, opts)` — opts is a Rhai Map with
// optional `delay_ms` and `max_attempts` keys.
{
let svc = svc.clone();
let cx = cx.clone();
module.set_native_fn(
"enqueue",
move |name: &str, message: Dynamic, opts: Map| -> Result<(), Box<EvalAltResult>> {
let json = message_to_json(&message)?;
let opts = parse_opts(&opts)?;
enqueue_blocking(&svc, &cx, name, json, opts)
},
);
}
// `queue::depth(name)` — total rows in the queue.
{
let svc = svc.clone();
let cx = cx.clone();
module.set_native_fn(
"depth",
move |name: &str| -> Result<i64, Box<EvalAltResult>> {
let svc = svc.clone();
let cx = cx.clone();
let name = name.to_string();
block_on_u64(async move { svc.depth(&cx, &name).await })
},
);
}
// `queue::depth_pending(name)` — only currently-claimable rows.
{
let svc = svc.clone();
let cx = cx.clone();
module.set_native_fn(
"depth_pending",
move |name: &str| -> Result<i64, Box<EvalAltResult>> {
let svc = svc.clone();
let cx = cx.clone();
let name = name.to_string();
block_on_u64(async move { svc.depth_pending(&cx, &name).await })
},
);
}
engine.register_static_module("queue", module.into());
}
/// Run the async enqueue inside the synchronous Rhai context. Mirrors
/// `pubsub::block_on` but for the queue's `Result<_, QueueError>`.
fn enqueue_blocking(
svc: &Arc<dyn QueueService>,
cx: &Arc<SdkCallCx>,
name: &str,
payload: Json,
opts: EnqueueOpts,
) -> Result<(), Box<EvalAltResult>> {
let handle = TokioHandle::try_current().map_err(|e| -> Box<EvalAltResult> {
EvalAltResult::ErrorRuntime(
format!("queue: no tokio runtime available: {e}").into(),
rhai::Position::NONE,
)
.into()
})?;
let svc = svc.clone();
let cx = cx.clone();
let name = name.to_string();
handle
.block_on(async move { svc.enqueue(&cx, &name, payload, opts).await })
.map(|_id| ())
.map_err(|err| -> Box<EvalAltResult> {
EvalAltResult::ErrorRuntime(format!("queue: {err}").into(), rhai::Position::NONE).into()
})
}
fn block_on_u64<F>(fut: F) -> Result<i64, Box<EvalAltResult>>
where
F: std::future::Future<Output = Result<u64, QueueError>> + Send + 'static,
{
let handle = TokioHandle::try_current().map_err(|e| -> Box<EvalAltResult> {
EvalAltResult::ErrorRuntime(
format!("queue: no tokio runtime available: {e}").into(),
rhai::Position::NONE,
)
.into()
})?;
let n = handle.block_on(fut).map_err(|err| -> Box<EvalAltResult> {
EvalAltResult::ErrorRuntime(format!("queue: {err}").into(), rhai::Position::NONE).into()
})?;
Ok(i64::try_from(n).unwrap_or(i64::MAX))
}
fn parse_opts(opts: &Map) -> Result<EnqueueOpts, Box<EvalAltResult>> {
let mut out = EnqueueOpts::default();
if let Some(v) = opts.get("delay_ms") {
out.delay_ms = Some(v.as_int().map_err(|_| -> Box<EvalAltResult> {
EvalAltResult::ErrorRuntime(
"queue::enqueue: opts.delay_ms must be an integer".into(),
rhai::Position::NONE,
)
.into()
})?);
}
if let Some(v) = opts.get("max_attempts") {
let n = v.as_int().map_err(|_| -> Box<EvalAltResult> {
EvalAltResult::ErrorRuntime(
"queue::enqueue: opts.max_attempts must be an integer".into(),
rhai::Position::NONE,
)
.into()
})?;
out.max_attempts = Some(u32::try_from(n).unwrap_or(0));
}
Ok(out)
}
/// Convert a Rhai `Dynamic` into JSON. Mirrors `pubsub::message_to_json`
/// — blobs become base64, FnPtr / closures are rejected so a script
/// can't accidentally enqueue something that won't survive a trip
/// through Postgres + back through the bridge.
fn message_to_json(value: &Dynamic) -> Result<Json, Box<EvalAltResult>> {
if value.is::<rhai::FnPtr>() {
return Err(EvalAltResult::ErrorRuntime(
"queue::enqueue: messages must not contain FnPtr / closures".into(),
rhai::Position::NONE,
)
.into());
}
if value.is_blob() {
let blob = value.clone().into_blob().unwrap_or_default();
return Ok(Json::String(STANDARD.encode(&blob)));
}
if value.is_unit() {
return Ok(Json::Null);
}
if let Ok(b) = value.as_bool() {
return Ok(Json::Bool(b));
}
if let Ok(i) = value.as_int() {
return Ok(Json::Number(i.into()));
}
if let Ok(f) = value.as_float() {
return Ok(serde_json::Number::from_f64(f).map_or(Json::Null, Json::Number));
}
if value.is_string() {
return Ok(Json::String(
value.clone().into_string().unwrap_or_default(),
));
}
if let Some(arr) = value.clone().try_cast::<Array>() {
let mut out = Vec::with_capacity(arr.len());
for v in &arr {
out.push(message_to_json(v)?);
}
return Ok(Json::Array(out));
}
if let Some(map) = value.clone().try_cast::<Map>() {
let mut out = serde_json::Map::new();
for (k, v) in map {
out.insert(k.to_string(), message_to_json(&v)?);
}
return Ok(Json::Object(out));
}
Ok(Json::String(value.to_string()))
}
#[cfg(test)]
mod tests {
use super::*;
use rhai::Dynamic;
#[test]
fn message_blob_encodes_to_base64_string() {
let blob: rhai::Blob = vec![0x01, 0x02, 0x03];
let d = Dynamic::from(blob);
let json = message_to_json(&d).unwrap();
// STANDARD base64 of [1,2,3] is "AQID"
assert_eq!(json, Json::String("AQID".into()));
}
#[test]
fn message_nested_map_round_trips_through_json() {
let mut m = Map::new();
m.insert("k".into(), Dynamic::from(42_i64));
let d = Dynamic::from(m);
let json = message_to_json(&d).unwrap();
let expected = serde_json::json!({ "k": 42 });
assert_eq!(json, expected);
}
#[test]
fn message_rejects_fnptr() {
// Build a Rhai FnPtr and confirm we reject it.
let f = rhai::FnPtr::new("anything").unwrap();
let d = Dynamic::from(f);
let err = message_to_json(&d).unwrap_err();
assert!(err.to_string().contains("FnPtr"));
}
#[test]
fn parse_opts_accepts_partial_overrides() {
let mut m = Map::new();
m.insert("delay_ms".into(), Dynamic::from(500_i64));
let opts = parse_opts(&m).unwrap();
assert_eq!(opts.delay_ms, Some(500));
assert_eq!(opts.max_attempts, None);
}
#[test]
fn parse_opts_max_attempts_only() {
let mut m = Map::new();
m.insert("max_attempts".into(), Dynamic::from(5_i64));
let opts = parse_opts(&m).unwrap();
assert_eq!(opts.delay_ms, None);
assert_eq!(opts.max_attempts, Some(5));
}
#[test]
fn parse_opts_rejects_non_integer_delay() {
let mut m = Map::new();
m.insert("delay_ms".into(), Dynamic::from("nope"));
assert!(parse_opts(&m).is_err());
}
}