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:
103
crates/shared/src/group_queue.rs
Normal file
103
crates/shared/src/group_queue.rs
Normal file
@@ -0,0 +1,103 @@
|
||||
//! `GroupQueueService` — the §11.6 D3 shared group-QUEUE contract.
|
||||
//!
|
||||
//! The durable-queue counterpart to [`crate::GroupKvService`]. A script reaches
|
||||
//! it via the explicit `queue::shared_collection("name")` handle (distinct from
|
||||
//! the app-private `queue::enqueue`). Any subtree app ENQUEUES into one group-
|
||||
//! owned store; CONSUMPTION is by competing per-descendant materialized
|
||||
//! consumers (handled outside this trait, in the dispatcher). The impl resolves
|
||||
//! the OWNING GROUP by walking the calling app's ancestor chain for the nearest
|
||||
//! group that declares the queue shared — the isolation boundary.
|
||||
//!
|
||||
//! Trust model (§11.6): enqueue is a WRITE to shared state, so it requires an
|
||||
//! authenticated principal with editor+ on the owning group (fails closed for
|
||||
//! an anonymous caller), matching `GroupKvService` writes.
|
||||
|
||||
use async_trait::async_trait;
|
||||
use thiserror::Error;
|
||||
|
||||
use crate::{EnqueueOpts, QueueMessageId, SdkCallCx};
|
||||
|
||||
#[async_trait]
|
||||
pub trait GroupQueueService: Send + Sync {
|
||||
/// Enqueue `payload` into the group shared queue `collection`. Resolves the
|
||||
/// owning group (kind `queue`) from `cx.app_id`'s chain and requires editor+.
|
||||
async fn enqueue(
|
||||
&self,
|
||||
cx: &SdkCallCx,
|
||||
collection: &str,
|
||||
payload: serde_json::Value,
|
||||
opts: EnqueueOpts,
|
||||
) -> Result<QueueMessageId, GroupQueueError>;
|
||||
|
||||
/// Total rows in the shared queue.
|
||||
async fn depth(&self, cx: &SdkCallCx, collection: &str) -> Result<u64, GroupQueueError>;
|
||||
|
||||
/// Currently-claimable rows in the shared queue.
|
||||
async fn depth_pending(&self, cx: &SdkCallCx, collection: &str)
|
||||
-> Result<u64, GroupQueueError>;
|
||||
}
|
||||
|
||||
/// Stub for test bundles that don't exercise shared queues. Every call errors so
|
||||
/// accidental use surfaces clearly.
|
||||
#[derive(Debug, Default, Clone, Copy)]
|
||||
pub struct NoopGroupQueueService;
|
||||
|
||||
#[async_trait]
|
||||
impl GroupQueueService for NoopGroupQueueService {
|
||||
async fn enqueue(
|
||||
&self,
|
||||
_cx: &SdkCallCx,
|
||||
_collection: &str,
|
||||
_payload: serde_json::Value,
|
||||
_opts: EnqueueOpts,
|
||||
) -> Result<QueueMessageId, GroupQueueError> {
|
||||
Err(GroupQueueError::Backend(
|
||||
"group queue is not wired in".into(),
|
||||
))
|
||||
}
|
||||
|
||||
async fn depth(&self, _cx: &SdkCallCx, _collection: &str) -> Result<u64, GroupQueueError> {
|
||||
Err(GroupQueueError::Backend(
|
||||
"group queue is not wired in".into(),
|
||||
))
|
||||
}
|
||||
|
||||
async fn depth_pending(
|
||||
&self,
|
||||
_cx: &SdkCallCx,
|
||||
_collection: &str,
|
||||
) -> Result<u64, GroupQueueError> {
|
||||
Err(GroupQueueError::Backend(
|
||||
"group queue is not wired in".into(),
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
/// Failure modes surfaced to the Rhai bridge.
|
||||
#[derive(Debug, Error)]
|
||||
pub enum GroupQueueError {
|
||||
/// Empty collection name; rejected at the SDK boundary.
|
||||
#[error("queue name must not be empty")]
|
||||
InvalidCollection,
|
||||
|
||||
/// No group on the calling app's ancestor chain declares this queue shared.
|
||||
#[error("queue {0:?} is not a shared group queue for this app")]
|
||||
CollectionNotShared(String),
|
||||
|
||||
/// Caller lacked the required capability. Enqueue always needs an
|
||||
/// authenticated editor+ on the owning group (fails closed for anon).
|
||||
#[error("forbidden")]
|
||||
Forbidden,
|
||||
|
||||
/// Payload exceeds the per-message JSON-encoded size cap.
|
||||
#[error("payload too large: {actual} bytes exceeds the {limit}-byte cap")]
|
||||
PayloadTooLarge { limit: usize, actual: usize },
|
||||
|
||||
/// Invalid enqueue options (bad max_attempts / delay_ms).
|
||||
#[error("{0}")]
|
||||
InvalidOpts(String),
|
||||
|
||||
/// Storage / backend failure.
|
||||
#[error("backend error: {0}")]
|
||||
Backend(String),
|
||||
}
|
||||
@@ -20,6 +20,7 @@ pub mod group_docs;
|
||||
pub mod group_files;
|
||||
pub mod group_kv;
|
||||
pub mod group_pubsub;
|
||||
pub mod group_queue;
|
||||
pub mod http;
|
||||
pub mod ids;
|
||||
pub mod inbox;
|
||||
@@ -73,6 +74,7 @@ pub use group_docs::{GroupDocsError, GroupDocsService, NoopGroupDocsService};
|
||||
pub use group_files::{GroupFilesError, GroupFilesService, NoopGroupFilesService};
|
||||
pub use group_kv::{GroupKvError, GroupKvService, NoopGroupKvService};
|
||||
pub use group_pubsub::{GroupPubsubError, GroupPubsubService, NoopGroupPubsubService};
|
||||
pub use group_queue::{GroupQueueError, GroupQueueService, NoopGroupQueueService};
|
||||
pub use http::{HttpError, HttpRequest, HttpResponse, HttpService, NoopHttpService};
|
||||
pub use ids::{
|
||||
AdminUserId, ApiKeyId, AppId, AppUserId, ExecutionId, GroupId, InvitationId, QueueMessageId,
|
||||
|
||||
@@ -21,12 +21,13 @@ use std::sync::Arc;
|
||||
|
||||
use crate::{
|
||||
DeadLetterService, DocsService, EmailService, FilesService, GroupDocsService,
|
||||
GroupFilesService, GroupKvService, GroupPubsubService, HttpService, InvokeService, KvService,
|
||||
ModuleSource, NoopDeadLetterService, NoopDocsService, NoopEmailService, NoopEventEmitter,
|
||||
NoopFilesService, NoopGroupDocsService, NoopGroupFilesService, NoopGroupKvService,
|
||||
NoopGroupPubsubService, NoopHttpService, NoopInvokeService, NoopKvService, NoopModuleSource,
|
||||
NoopPubsubService, NoopQueueService, NoopSecretsService, NoopUsersService, NoopVarsService,
|
||||
PubsubService, QueueService, SecretsService, ServiceEventEmitter, UsersService, VarsService,
|
||||
GroupFilesService, GroupKvService, GroupPubsubService, GroupQueueService, HttpService,
|
||||
InvokeService, KvService, ModuleSource, NoopDeadLetterService, NoopDocsService,
|
||||
NoopEmailService, NoopEventEmitter, NoopFilesService, NoopGroupDocsService,
|
||||
NoopGroupFilesService, NoopGroupKvService, NoopGroupPubsubService, NoopGroupQueueService,
|
||||
NoopHttpService, NoopInvokeService, NoopKvService, NoopModuleSource, NoopPubsubService,
|
||||
NoopQueueService, NoopSecretsService, NoopUsersService, NoopVarsService, PubsubService,
|
||||
QueueService, SecretsService, ServiceEventEmitter, UsersService, VarsService,
|
||||
};
|
||||
|
||||
/// SDK service bundle. See module docs for the lifecycle and the v1.1.x
|
||||
@@ -139,6 +140,12 @@ pub struct Services {
|
||||
/// `shared = true` group pubsub triggers. Wired via
|
||||
/// [`Services::with_group_pubsub`]; defaults to `NoopGroupPubsubService`.
|
||||
pub group_pubsub: Arc<dyn GroupPubsubService>,
|
||||
|
||||
/// Shared cross-app group QUEUES (§11.6 D3). Scripts get
|
||||
/// `queue::shared_collection(name)` — enqueue into a group-keyed store
|
||||
/// drained by competing per-descendant consumers. Wired via
|
||||
/// [`Services::with_group_queue`]; defaults to `NoopGroupQueueService`.
|
||||
pub group_queue: Arc<dyn GroupQueueService>,
|
||||
}
|
||||
|
||||
impl Services {
|
||||
@@ -185,6 +192,7 @@ impl Services {
|
||||
group_docs: Arc::new(NoopGroupDocsService),
|
||||
group_files: Arc::new(NoopGroupFilesService),
|
||||
group_pubsub: Arc::new(NoopGroupPubsubService),
|
||||
group_queue: Arc::new(NoopGroupQueueService),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -217,6 +225,13 @@ impl Services {
|
||||
self
|
||||
}
|
||||
|
||||
/// Set the §11.6 D3 shared group-QUEUE service (picloud binary; tests leave noop).
|
||||
#[must_use]
|
||||
pub fn with_group_queue(mut self, group_queue: Arc<dyn GroupQueueService>) -> Self {
|
||||
self.group_queue = group_queue;
|
||||
self
|
||||
}
|
||||
|
||||
/// All-noop bundle for tests that build an `Engine` but don't
|
||||
/// exercise the stateful services. Returns the same shape as
|
||||
/// `Services::new` so callers can't accidentally rely on a stub
|
||||
|
||||
Reference in New Issue
Block a user