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:
@@ -170,6 +170,10 @@ pub enum Capability {
|
||||
/// group; fails closed for an anonymous principal, like `GroupKvWrite` — a
|
||||
/// publish is a write to shared state.
|
||||
GroupPubsubPublish(GroupId),
|
||||
/// Enqueue into a group-owned shared QUEUE (§11.6 D3). editor+ on the owning
|
||||
/// group; fails closed for an anonymous principal, like `GroupKvWrite` — an
|
||||
/// enqueue is a write to shared state.
|
||||
GroupQueueEnqueue(GroupId),
|
||||
/// Send an outbound email from a script in this app (v1.1.7). Maps
|
||||
/// to `script:write` on API keys (sending mail is an outbound
|
||||
/// side-effect like an HTTP request). Granted to `editor`+.
|
||||
@@ -243,7 +247,8 @@ impl Capability {
|
||||
| Self::GroupDocsWrite(_)
|
||||
| Self::GroupFilesRead(_)
|
||||
| Self::GroupFilesWrite(_)
|
||||
| Self::GroupPubsubPublish(_) => None,
|
||||
| Self::GroupPubsubPublish(_)
|
||||
| Self::GroupQueueEnqueue(_) => None,
|
||||
Self::AppRead(id)
|
||||
| Self::AppWriteScript(id)
|
||||
| Self::AppWriteRoute(id)
|
||||
@@ -321,6 +326,7 @@ impl Capability {
|
||||
| Self::GroupDocsWrite(_)
|
||||
| Self::GroupFilesWrite(_)
|
||||
| Self::GroupPubsubPublish(_)
|
||||
| Self::GroupQueueEnqueue(_)
|
||||
| Self::AppInvoke(_) => Scope::ScriptWrite,
|
||||
Self::AppWriteRoute(_) => Scope::RouteWrite,
|
||||
Self::AppManageDomains(_) => Scope::DomainManage,
|
||||
@@ -543,7 +549,8 @@ async fn role_grants(
|
||||
| Capability::GroupDocsWrite(g)
|
||||
| Capability::GroupFilesRead(g)
|
||||
| Capability::GroupFilesWrite(g)
|
||||
| Capability::GroupPubsubPublish(g) => {
|
||||
| Capability::GroupPubsubPublish(g)
|
||||
| Capability::GroupQueueEnqueue(g) => {
|
||||
group_member_grants(repo, principal.user_id, cap, g).await
|
||||
}
|
||||
// Creating a root-level group is an instance act — members
|
||||
@@ -618,7 +625,8 @@ const fn group_role_satisfies(role: AppRole, cap: Capability) -> bool {
|
||||
| Capability::GroupKvWrite(_)
|
||||
| Capability::GroupDocsWrite(_)
|
||||
| Capability::GroupFilesWrite(_)
|
||||
| Capability::GroupPubsubPublish(_) => {
|
||||
| Capability::GroupPubsubPublish(_)
|
||||
| Capability::GroupQueueEnqueue(_) => {
|
||||
matches!(role, AppRole::Editor | AppRole::AppAdmin)
|
||||
}
|
||||
// group_admin manages the group + reads secret VALUES (the
|
||||
|
||||
294
crates/manager-core/src/group_queue_repo.rs
Normal file
294
crates/manager-core/src/group_queue_repo.rs
Normal file
@@ -0,0 +1,294 @@
|
||||
//! `GroupQueueRepo` — CRUD over `group_queue_messages`, the §11.6 D3 group-
|
||||
//! shared durable queue store.
|
||||
//!
|
||||
//! The group counterpart to [`crate::queue_repo::QueueRepo`], keyed by
|
||||
//! `(group_id, collection)` instead of `(app_id, queue_name)`. Producers enqueue
|
||||
//! via `GroupQueueServiceImpl` (`queue::shared_collection(name).enqueue(...)`);
|
||||
//! the dispatcher's queue arm claims from here for a materialized shared-queue
|
||||
//! consumer (competing consumers — every descendant app runs a consumer copy,
|
||||
//! all claiming this one store with `FOR UPDATE SKIP LOCKED`, so each message is
|
||||
//! delivered at-most-once across the subtree).
|
||||
//!
|
||||
//! Deferred (documented): shared-queue dead-lettering — an exhausted message is
|
||||
//! nacked with backoff by the dispatcher (never silently dropped), but there is
|
||||
//! no group dead-letter store yet.
|
||||
|
||||
use async_trait::async_trait;
|
||||
use chrono::{DateTime, Utc};
|
||||
use picloud_shared::{AdminUserId, GroupId, QueueMessageId};
|
||||
use sqlx::PgPool;
|
||||
use uuid::Uuid;
|
||||
|
||||
/// Cap on `depth`/`depth_pending` scans (mirrors `queue_repo`).
|
||||
const QUEUE_DEPTH_SCAN_CAP: i64 = 10_000;
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum GroupQueueRepoError {
|
||||
#[error("database error: {0}")]
|
||||
Db(#[from] sqlx::Error),
|
||||
}
|
||||
|
||||
/// Insert payload — what `GroupQueueService::enqueue` hands the repo.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct NewGroupQueueMessage {
|
||||
pub group_id: GroupId,
|
||||
pub collection: String,
|
||||
pub payload: serde_json::Value,
|
||||
pub deliver_after: Option<DateTime<Utc>>,
|
||||
pub max_attempts: u32,
|
||||
pub enqueued_by_principal: Option<AdminUserId>,
|
||||
}
|
||||
|
||||
/// One claimed message ready for handler dispatch.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ClaimedGroupMessage {
|
||||
pub id: QueueMessageId,
|
||||
pub group_id: GroupId,
|
||||
pub collection: String,
|
||||
pub payload: serde_json::Value,
|
||||
pub enqueued_at: DateTime<Utc>,
|
||||
pub attempt: u32,
|
||||
pub max_attempts: u32,
|
||||
pub claim_token: Uuid,
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
pub trait GroupQueueRepo: Send + Sync {
|
||||
async fn enqueue(
|
||||
&self,
|
||||
msg: NewGroupQueueMessage,
|
||||
) -> Result<QueueMessageId, GroupQueueRepoError>;
|
||||
|
||||
/// Atomic claim of one ready message from `(group_id, collection)` with
|
||||
/// `FOR UPDATE SKIP LOCKED` — the competing-consumer primitive. `Ok(None)`
|
||||
/// when nothing is claimable.
|
||||
async fn claim(
|
||||
&self,
|
||||
group_id: GroupId,
|
||||
collection: &str,
|
||||
) -> Result<Option<ClaimedGroupMessage>, GroupQueueRepoError>;
|
||||
|
||||
/// Handler succeeded: delete the row iff `claim_token` matches.
|
||||
async fn ack(
|
||||
&self,
|
||||
message_id: QueueMessageId,
|
||||
claim_token: Uuid,
|
||||
) -> Result<bool, GroupQueueRepoError>;
|
||||
|
||||
/// Handler threw: clear the claim, defer redelivery by `retry_delay`.
|
||||
async fn nack(
|
||||
&self,
|
||||
message_id: QueueMessageId,
|
||||
claim_token: Uuid,
|
||||
retry_delay: chrono::Duration,
|
||||
) -> Result<bool, GroupQueueRepoError>;
|
||||
|
||||
/// Drop a message that exhausted its attempts (no group dead-letter store
|
||||
/// yet — see the module deferral note). Deletes iff `claim_token` matches.
|
||||
async fn drop_exhausted(
|
||||
&self,
|
||||
message_id: QueueMessageId,
|
||||
claim_token: Uuid,
|
||||
) -> Result<bool, GroupQueueRepoError>;
|
||||
|
||||
/// Periodic safety net: clear claims older than a shared-queue consumer's
|
||||
/// `visibility_timeout_secs`. Returns the number of rows reclaimed.
|
||||
async fn reclaim_visibility_timeouts(&self) -> Result<u64, GroupQueueRepoError>;
|
||||
|
||||
async fn depth(&self, group_id: GroupId, collection: &str) -> Result<u64, GroupQueueRepoError>;
|
||||
|
||||
async fn depth_pending(
|
||||
&self,
|
||||
group_id: GroupId,
|
||||
collection: &str,
|
||||
) -> Result<u64, GroupQueueRepoError>;
|
||||
}
|
||||
|
||||
pub struct PostgresGroupQueueRepo {
|
||||
pool: PgPool,
|
||||
}
|
||||
|
||||
impl PostgresGroupQueueRepo {
|
||||
#[must_use]
|
||||
pub fn new(pool: PgPool) -> Self {
|
||||
Self { pool }
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl GroupQueueRepo for PostgresGroupQueueRepo {
|
||||
async fn enqueue(
|
||||
&self,
|
||||
msg: NewGroupQueueMessage,
|
||||
) -> Result<QueueMessageId, GroupQueueRepoError> {
|
||||
let (id,): (Uuid,) = sqlx::query_as(
|
||||
"INSERT INTO group_queue_messages ( \
|
||||
group_id, collection, payload, deliver_after, \
|
||||
max_attempts, enqueued_by_principal \
|
||||
) VALUES ($1, $2, $3, $4, $5, $6) RETURNING id",
|
||||
)
|
||||
.bind(msg.group_id.into_inner())
|
||||
.bind(&msg.collection)
|
||||
.bind(&msg.payload)
|
||||
.bind(msg.deliver_after)
|
||||
.bind(i32::try_from(msg.max_attempts).unwrap_or(3))
|
||||
.bind(msg.enqueued_by_principal.map(AdminUserId::into_inner))
|
||||
.fetch_one(&self.pool)
|
||||
.await?;
|
||||
Ok(id.into())
|
||||
}
|
||||
|
||||
async fn claim(
|
||||
&self,
|
||||
group_id: GroupId,
|
||||
collection: &str,
|
||||
) -> Result<Option<ClaimedGroupMessage>, GroupQueueRepoError> {
|
||||
let token = Uuid::new_v4();
|
||||
let row: Option<ClaimedRow> = sqlx::query_as(
|
||||
"UPDATE group_queue_messages \
|
||||
SET claim_token = $3, claimed_at = NOW(), attempt = attempt + 1 \
|
||||
WHERE id = ( \
|
||||
SELECT id FROM group_queue_messages \
|
||||
WHERE group_id = $1 AND collection = $2 \
|
||||
AND claim_token IS NULL \
|
||||
AND (deliver_after IS NULL OR deliver_after <= NOW()) \
|
||||
ORDER BY enqueued_at \
|
||||
FOR UPDATE SKIP LOCKED \
|
||||
LIMIT 1 \
|
||||
) \
|
||||
RETURNING id, group_id, collection, payload, enqueued_at, attempt, max_attempts",
|
||||
)
|
||||
.bind(group_id.into_inner())
|
||||
.bind(collection)
|
||||
.bind(token)
|
||||
.fetch_optional(&self.pool)
|
||||
.await?;
|
||||
Ok(row.map(|r| ClaimedGroupMessage {
|
||||
id: r.id.into(),
|
||||
group_id: r.group_id.into(),
|
||||
collection: r.collection,
|
||||
payload: r.payload,
|
||||
enqueued_at: r.enqueued_at,
|
||||
attempt: u32::try_from(r.attempt).unwrap_or(1),
|
||||
max_attempts: u32::try_from(r.max_attempts).unwrap_or(3),
|
||||
claim_token: token,
|
||||
}))
|
||||
}
|
||||
|
||||
async fn ack(
|
||||
&self,
|
||||
message_id: QueueMessageId,
|
||||
claim_token: Uuid,
|
||||
) -> Result<bool, GroupQueueRepoError> {
|
||||
let res =
|
||||
sqlx::query("DELETE FROM group_queue_messages WHERE id = $1 AND claim_token = $2")
|
||||
.bind(message_id.into_inner())
|
||||
.bind(claim_token)
|
||||
.execute(&self.pool)
|
||||
.await?;
|
||||
Ok(res.rows_affected() == 1)
|
||||
}
|
||||
|
||||
async fn nack(
|
||||
&self,
|
||||
message_id: QueueMessageId,
|
||||
claim_token: Uuid,
|
||||
retry_delay: chrono::Duration,
|
||||
) -> Result<bool, GroupQueueRepoError> {
|
||||
let next = Utc::now() + retry_delay;
|
||||
let res = sqlx::query(
|
||||
"UPDATE group_queue_messages \
|
||||
SET claim_token = NULL, claimed_at = NULL, deliver_after = $3 \
|
||||
WHERE id = $1 AND claim_token = $2",
|
||||
)
|
||||
.bind(message_id.into_inner())
|
||||
.bind(claim_token)
|
||||
.bind(next)
|
||||
.execute(&self.pool)
|
||||
.await?;
|
||||
Ok(res.rows_affected() == 1)
|
||||
}
|
||||
|
||||
async fn drop_exhausted(
|
||||
&self,
|
||||
message_id: QueueMessageId,
|
||||
claim_token: Uuid,
|
||||
) -> Result<bool, GroupQueueRepoError> {
|
||||
let res =
|
||||
sqlx::query("DELETE FROM group_queue_messages WHERE id = $1 AND claim_token = $2")
|
||||
.bind(message_id.into_inner())
|
||||
.bind(claim_token)
|
||||
.execute(&self.pool)
|
||||
.await?;
|
||||
Ok(res.rows_affected() == 1)
|
||||
}
|
||||
|
||||
async fn reclaim_visibility_timeouts(&self) -> Result<u64, GroupQueueRepoError> {
|
||||
// A shared-queue consumer is a materialized app-owned trigger
|
||||
// (`shared = TRUE`, `group_id` on the SOURCE template) whose
|
||||
// queue_trigger_details.queue_name IS the shared collection name. Join
|
||||
// the group template to recover the owning group + visibility timeout.
|
||||
let res = sqlx::query(
|
||||
"UPDATE group_queue_messages m \
|
||||
SET claim_token = NULL, claimed_at = NULL \
|
||||
FROM triggers copy \
|
||||
JOIN triggers tmpl ON tmpl.id = copy.materialized_from \
|
||||
JOIN queue_trigger_details d ON d.trigger_id = copy.id \
|
||||
WHERE tmpl.group_id = m.group_id \
|
||||
AND d.queue_name = m.collection \
|
||||
AND tmpl.shared = TRUE \
|
||||
AND m.claim_token IS NOT NULL \
|
||||
AND m.claimed_at < NOW() - (d.visibility_timeout_secs || ' seconds')::INTERVAL",
|
||||
)
|
||||
.execute(&self.pool)
|
||||
.await?;
|
||||
Ok(res.rows_affected())
|
||||
}
|
||||
|
||||
async fn depth(&self, group_id: GroupId, collection: &str) -> Result<u64, GroupQueueRepoError> {
|
||||
let (n,): (i64,) = sqlx::query_as(
|
||||
"SELECT COUNT(*) FROM ( \
|
||||
SELECT 1 FROM group_queue_messages \
|
||||
WHERE group_id = $1 AND collection = $2 LIMIT $3 \
|
||||
) sub",
|
||||
)
|
||||
.bind(group_id.into_inner())
|
||||
.bind(collection)
|
||||
.bind(QUEUE_DEPTH_SCAN_CAP)
|
||||
.fetch_one(&self.pool)
|
||||
.await?;
|
||||
Ok(u64::try_from(n).unwrap_or(0))
|
||||
}
|
||||
|
||||
async fn depth_pending(
|
||||
&self,
|
||||
group_id: GroupId,
|
||||
collection: &str,
|
||||
) -> Result<u64, GroupQueueRepoError> {
|
||||
let (n,): (i64,) = sqlx::query_as(
|
||||
"SELECT COUNT(*) FROM ( \
|
||||
SELECT 1 FROM group_queue_messages \
|
||||
WHERE group_id = $1 AND collection = $2 \
|
||||
AND claim_token IS NULL \
|
||||
AND (deliver_after IS NULL OR deliver_after <= NOW()) LIMIT $3 \
|
||||
) sub",
|
||||
)
|
||||
.bind(group_id.into_inner())
|
||||
.bind(collection)
|
||||
.bind(QUEUE_DEPTH_SCAN_CAP)
|
||||
.fetch_one(&self.pool)
|
||||
.await?;
|
||||
Ok(u64::try_from(n).unwrap_or(0))
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(sqlx::FromRow)]
|
||||
struct ClaimedRow {
|
||||
id: Uuid,
|
||||
group_id: Uuid,
|
||||
collection: String,
|
||||
payload: serde_json::Value,
|
||||
enqueued_at: DateTime<Utc>,
|
||||
attempt: i32,
|
||||
max_attempts: i32,
|
||||
}
|
||||
152
crates/manager-core/src/group_queue_service.rs
Normal file
152
crates/manager-core/src/group_queue_service.rs
Normal file
@@ -0,0 +1,152 @@
|
||||
//! `GroupQueueServiceImpl` — wires `GroupQueueRepo` + the group-collection
|
||||
//! registry underneath the `picloud_shared::GroupQueueService` trait that
|
||||
//! scripts reach via the `queue::shared_collection("name")` Rhai handle
|
||||
//! (§11.6 D3).
|
||||
//!
|
||||
//! Resolves the OWNING GROUP (kind `queue`) from `cx.app_id`'s chain (the
|
||||
//! isolation boundary), enforces editor+ (`GroupQueueEnqueue`, fails closed on
|
||||
//! anon — enqueue is a shared write), caps the payload size, then writes to the
|
||||
//! group-keyed store. Consumption is out of band (competing per-descendant
|
||||
//! materialized consumers, in the dispatcher).
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use async_trait::async_trait;
|
||||
use chrono::Utc;
|
||||
use picloud_shared::{
|
||||
EnqueueOpts, GroupId, GroupQueueError, GroupQueueService, QueueMessageId, SdkCallCx,
|
||||
};
|
||||
|
||||
use crate::authz::{self, AuthzRepo, Capability};
|
||||
use crate::group_collection_repo::GroupCollectionResolver;
|
||||
use crate::group_queue_repo::{GroupQueueRepo, NewGroupQueueMessage};
|
||||
use crate::queue_service::queue_max_payload_bytes_from_env;
|
||||
|
||||
/// The registry `kind` this service resolves.
|
||||
const KIND_QUEUE: &str = "queue";
|
||||
|
||||
pub struct GroupQueueServiceImpl {
|
||||
repo: Arc<dyn GroupQueueRepo>,
|
||||
resolver: Arc<dyn GroupCollectionResolver>,
|
||||
authz: Arc<dyn AuthzRepo>,
|
||||
max_payload_bytes: usize,
|
||||
}
|
||||
|
||||
impl GroupQueueServiceImpl {
|
||||
#[must_use]
|
||||
pub fn new(
|
||||
repo: Arc<dyn GroupQueueRepo>,
|
||||
resolver: Arc<dyn GroupCollectionResolver>,
|
||||
authz: Arc<dyn AuthzRepo>,
|
||||
) -> Self {
|
||||
Self {
|
||||
repo,
|
||||
resolver,
|
||||
authz,
|
||||
max_payload_bytes: queue_max_payload_bytes_from_env(),
|
||||
}
|
||||
}
|
||||
|
||||
async fn owning_group(
|
||||
&self,
|
||||
cx: &SdkCallCx,
|
||||
collection: &str,
|
||||
) -> Result<GroupId, GroupQueueError> {
|
||||
if collection.is_empty() {
|
||||
return Err(GroupQueueError::InvalidCollection);
|
||||
}
|
||||
self.resolver
|
||||
.resolve_owning_group(cx.app_id, collection, KIND_QUEUE)
|
||||
.await
|
||||
.map_err(|e| GroupQueueError::Backend(e.to_string()))?
|
||||
.ok_or_else(|| GroupQueueError::CollectionNotShared(collection.to_string()))
|
||||
}
|
||||
|
||||
async fn check_write(&self, cx: &SdkCallCx, group_id: GroupId) -> Result<(), GroupQueueError> {
|
||||
authz::script_gate_require_principal(
|
||||
&*self.authz,
|
||||
cx,
|
||||
Capability::GroupQueueEnqueue(group_id),
|
||||
|| GroupQueueError::Forbidden,
|
||||
GroupQueueError::Backend,
|
||||
)
|
||||
.await
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl GroupQueueService for GroupQueueServiceImpl {
|
||||
async fn enqueue(
|
||||
&self,
|
||||
cx: &SdkCallCx,
|
||||
collection: &str,
|
||||
payload: serde_json::Value,
|
||||
opts: EnqueueOpts,
|
||||
) -> Result<QueueMessageId, GroupQueueError> {
|
||||
if collection.is_empty() {
|
||||
return Err(GroupQueueError::InvalidCollection);
|
||||
}
|
||||
// Size cap first (no DB) — a cheap reject before resolve/authz.
|
||||
let encoded_len = serde_json::to_vec(&payload)
|
||||
.map(|v| v.len())
|
||||
.map_err(|e| GroupQueueError::Backend(format!("encode payload: {e}")))?;
|
||||
if encoded_len > self.max_payload_bytes {
|
||||
return Err(GroupQueueError::PayloadTooLarge {
|
||||
limit: self.max_payload_bytes,
|
||||
actual: encoded_len,
|
||||
});
|
||||
}
|
||||
let max_attempts = opts.max_attempts.unwrap_or(3);
|
||||
if !(1..=20).contains(&max_attempts) {
|
||||
return Err(GroupQueueError::InvalidOpts(
|
||||
"queue::enqueue: max_attempts must be in [1, 20]".into(),
|
||||
));
|
||||
}
|
||||
if let Some(delay) = opts.delay_ms {
|
||||
if !(0..=86_400_000).contains(&delay) {
|
||||
return Err(GroupQueueError::InvalidOpts(
|
||||
"queue::enqueue: delay_ms must be in [0, 86_400_000]".into(),
|
||||
));
|
||||
}
|
||||
}
|
||||
let group_id = self.owning_group(cx, collection).await?;
|
||||
self.check_write(cx, group_id).await?;
|
||||
|
||||
let deliver_after = opts
|
||||
.delay_ms
|
||||
.and_then(chrono::Duration::try_milliseconds)
|
||||
.map(|d| Utc::now() + d);
|
||||
self.repo
|
||||
.enqueue(NewGroupQueueMessage {
|
||||
group_id,
|
||||
collection: collection.to_string(),
|
||||
payload,
|
||||
deliver_after,
|
||||
max_attempts,
|
||||
enqueued_by_principal: cx.principal.as_ref().map(|p| p.user_id),
|
||||
})
|
||||
.await
|
||||
.map_err(|e| GroupQueueError::Backend(e.to_string()))
|
||||
}
|
||||
|
||||
async fn depth(&self, cx: &SdkCallCx, collection: &str) -> Result<u64, GroupQueueError> {
|
||||
let group_id = self.owning_group(cx, collection).await?;
|
||||
// Depth is a read — reads are open (any subtree script).
|
||||
self.repo
|
||||
.depth(group_id, collection)
|
||||
.await
|
||||
.map_err(|e| GroupQueueError::Backend(e.to_string()))
|
||||
}
|
||||
|
||||
async fn depth_pending(
|
||||
&self,
|
||||
cx: &SdkCallCx,
|
||||
collection: &str,
|
||||
) -> Result<u64, GroupQueueError> {
|
||||
let group_id = self.owning_group(cx, collection).await?;
|
||||
self.repo
|
||||
.depth_pending(group_id, collection)
|
||||
.await
|
||||
.map_err(|e| GroupQueueError::Backend(e.to_string()))
|
||||
}
|
||||
}
|
||||
@@ -60,6 +60,8 @@ pub mod group_kv_repo;
|
||||
pub mod group_kv_service;
|
||||
pub mod group_members_repo;
|
||||
pub mod group_pubsub_service;
|
||||
pub mod group_queue_repo;
|
||||
pub mod group_queue_service;
|
||||
pub mod group_quota;
|
||||
pub mod group_repo;
|
||||
pub mod group_scripts_api;
|
||||
@@ -200,6 +202,10 @@ pub use group_members_repo::{
|
||||
PostgresGroupMembersRepository,
|
||||
};
|
||||
pub use group_pubsub_service::GroupPubsubServiceImpl;
|
||||
pub use group_queue_repo::{
|
||||
GroupQueueRepo, GroupQueueRepoError, NewGroupQueueMessage, PostgresGroupQueueRepo,
|
||||
};
|
||||
pub use group_queue_service::GroupQueueServiceImpl;
|
||||
pub use group_repo::{
|
||||
GroupChildCounts, GroupRepository, GroupRepositoryError, PostgresGroupRepository,
|
||||
ROOT_GROUP_SLUG,
|
||||
|
||||
Reference in New Issue
Block a user