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:
MechaCat02
2026-07-02 22:08:21 +02:00
parent 19ba6dbfb4
commit ccd3644aa4
12 changed files with 929 additions and 28 deletions

View 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),
}

View File

@@ -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,

View File

@@ -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