feat(shared-queues): group-keyed queue store + enqueue service + SDK (D3.1)
The producer side of shared durable queues:
- migration 0065 group_queue_messages (mirrors 0034, keyed by (group_id,
collection); CASCADE on group delete).
- GroupQueueRepo/PostgresGroupQueueRepo: enqueue + the competing-consumer
claim (FOR UPDATE SKIP LOCKED) + ack/nack/drop_exhausted/reclaim/depth.
- GroupQueueService trait (shared) + GroupQueueServiceImpl: resolve owning
group (kind='queue') from cx.app_id's chain, require editor+
(GroupQueueEnqueue, fails closed on anon), size-cap, enqueue.
- SDK: `queue::shared_collection("name")` -> GroupQueueHandle with
`.enqueue(msg[, opts])` / `.depth()` / `.depth_pending()`; wired through
Services + the picloud binary.
- authz: Capability::GroupQueueEnqueue(GroupId), editor+ / script:write.
Deterministic test proves competing consumers claim each message exactly
once. Consumption wiring (materialized consumers + dispatcher branch) lands
in D3.2.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -20,7 +20,9 @@ use std::sync::Arc;
|
||||
|
||||
use base64::engine::general_purpose::STANDARD;
|
||||
use base64::Engine as _;
|
||||
use picloud_shared::{EnqueueOpts, QueueError, QueueService, SdkCallCx, Services};
|
||||
use picloud_shared::{
|
||||
EnqueueOpts, GroupQueueService, 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;
|
||||
@@ -87,7 +89,106 @@ pub(super) fn register(engine: &mut RhaiEngine, services: &Services, cx: Arc<Sdk
|
||||
);
|
||||
}
|
||||
|
||||
// §11.6 D3: `queue::shared_collection("name")` → a handle over the group
|
||||
// shared queue (any subtree app enqueues into one group-owned store;
|
||||
// competing per-descendant consumers drain it). The owning group resolves
|
||||
// from `cx.app_id` inside the service — never a script arg.
|
||||
{
|
||||
let group_svc = services.group_queue.clone();
|
||||
let cx = cx.clone();
|
||||
module.set_native_fn(
|
||||
"shared_collection",
|
||||
move |name: &str| -> Result<GroupQueueHandle, Box<EvalAltResult>> {
|
||||
if name.is_empty() {
|
||||
return Err("queue::shared_collection name must not be empty".into());
|
||||
}
|
||||
Ok(GroupQueueHandle {
|
||||
collection: name.to_string(),
|
||||
service: group_svc.clone(),
|
||||
cx: cx.clone(),
|
||||
})
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
engine.register_static_module("queue", module.into());
|
||||
engine.register_type_with_name::<GroupQueueHandle>("GroupQueueHandle");
|
||||
register_shared_queue_methods(engine);
|
||||
}
|
||||
|
||||
/// §11.6 D3 handle returned by `queue::shared_collection("name")`.
|
||||
#[derive(Clone)]
|
||||
pub struct GroupQueueHandle {
|
||||
collection: String,
|
||||
service: Arc<dyn GroupQueueService>,
|
||||
cx: Arc<SdkCallCx>,
|
||||
}
|
||||
|
||||
fn group_enqueue(
|
||||
h: &mut GroupQueueHandle,
|
||||
message: Dynamic,
|
||||
opts: EnqueueOpts,
|
||||
) -> Result<(), Box<EvalAltResult>> {
|
||||
let json = message_to_json(&message)?;
|
||||
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 service = h.service.clone();
|
||||
let cx = h.cx.clone();
|
||||
let collection = h.collection.clone();
|
||||
handle
|
||||
.block_on(async move { service.enqueue(&cx, &collection, json, opts).await })
|
||||
.map(|_id| ())
|
||||
.map_err(|err| -> Box<EvalAltResult> {
|
||||
EvalAltResult::ErrorRuntime(format!("queue: {err}").into(), rhai::Position::NONE).into()
|
||||
})
|
||||
}
|
||||
|
||||
fn group_depth<F, Fut>(h: &mut GroupQueueHandle, f: F) -> Result<i64, Box<EvalAltResult>>
|
||||
where
|
||||
F: FnOnce(Arc<dyn GroupQueueService>, Arc<SdkCallCx>, String) -> Fut,
|
||||
Fut: std::future::Future<Output = Result<u64, picloud_shared::GroupQueueError>>,
|
||||
{
|
||||
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 fut = f(h.service.clone(), h.cx.clone(), h.collection.clone());
|
||||
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 register_shared_queue_methods(engine: &mut RhaiEngine) {
|
||||
engine.register_fn("enqueue", |h: &mut GroupQueueHandle, message: Dynamic| {
|
||||
group_enqueue(h, message, EnqueueOpts::default())
|
||||
});
|
||||
engine.register_fn(
|
||||
"enqueue",
|
||||
|h: &mut GroupQueueHandle, message: Dynamic, opts: Map| {
|
||||
let opts = parse_opts(&opts)?;
|
||||
group_enqueue(h, message, opts)
|
||||
},
|
||||
);
|
||||
engine.register_fn("depth", |h: &mut GroupQueueHandle| {
|
||||
group_depth(
|
||||
h,
|
||||
|svc, cx, coll| async move { svc.depth(&cx, &coll).await },
|
||||
)
|
||||
});
|
||||
engine.register_fn("depth_pending", |h: &mut GroupQueueHandle| {
|
||||
group_depth(h, |svc, cx, coll| async move {
|
||||
svc.depth_pending(&cx, &coll).await
|
||||
})
|
||||
});
|
||||
}
|
||||
|
||||
/// Run the async enqueue inside the synchronous Rhai context. Mirrors
|
||||
|
||||
Reference in New Issue
Block a user