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:
141
crates/manager-core/tests/group_queue.rs
Normal file
141
crates/manager-core/tests/group_queue.rs
Normal file
@@ -0,0 +1,141 @@
|
||||
//! §11.6 D3 integration test: group-shared durable queue store.
|
||||
//!
|
||||
//! Proves the competing-consumer primitive: N messages enqueued into one
|
||||
//! group-keyed store are claimed EXACTLY ONCE across concurrent claimers (the
|
||||
//! `FOR UPDATE SKIP LOCKED` guarantee that makes per-descendant materialized
|
||||
//! consumers safe). Also checks ack removes a row and nack re-defers it.
|
||||
//!
|
||||
//! Deterministic: drives `GroupQueueRepo` directly (no dispatcher). Skips when
|
||||
//! `DATABASE_URL` is unset.
|
||||
|
||||
#![allow(clippy::too_many_lines)]
|
||||
|
||||
use std::collections::HashSet;
|
||||
|
||||
use picloud_manager_core::group_queue_repo::{
|
||||
GroupQueueRepo, NewGroupQueueMessage, PostgresGroupQueueRepo,
|
||||
};
|
||||
use picloud_shared::GroupId;
|
||||
use sqlx::postgres::PgPoolOptions;
|
||||
use sqlx::PgPool;
|
||||
use uuid::Uuid;
|
||||
|
||||
async fn pool_or_skip() -> Option<PgPool> {
|
||||
let Ok(url) = std::env::var("DATABASE_URL") else {
|
||||
eprintln!("group_queue: DATABASE_URL unset — skipping");
|
||||
return None;
|
||||
};
|
||||
let pool = PgPoolOptions::new()
|
||||
.max_connections(6)
|
||||
.connect(&url)
|
||||
.await
|
||||
.expect("connect");
|
||||
sqlx::migrate!("./migrations")
|
||||
.run(&pool)
|
||||
.await
|
||||
.expect("migrate");
|
||||
Some(pool)
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
|
||||
async fn competing_consumers_claim_each_message_exactly_once() {
|
||||
let Some(pool) = pool_or_skip().await else {
|
||||
return;
|
||||
};
|
||||
let sfx = Uuid::new_v4().simple().to_string();
|
||||
let g: (Uuid,) = sqlx::query_as("INSERT INTO groups (slug, name) VALUES ($1, $1) RETURNING id")
|
||||
.bind(format!("gq-g-{sfx}"))
|
||||
.fetch_one(&pool)
|
||||
.await
|
||||
.unwrap();
|
||||
let group = GroupId::from(g.0);
|
||||
let repo = PostgresGroupQueueRepo::new(pool.clone());
|
||||
|
||||
// Enqueue 50 distinct messages into the shared `tasks` queue.
|
||||
const N: usize = 50;
|
||||
for i in 0..N {
|
||||
repo.enqueue(NewGroupQueueMessage {
|
||||
group_id: group,
|
||||
collection: "tasks".into(),
|
||||
payload: serde_json::json!({ "i": i }),
|
||||
deliver_after: None,
|
||||
max_attempts: 3,
|
||||
enqueued_by_principal: None,
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
// Four concurrent "consumers" claim until the queue is drained, ack-ing each.
|
||||
// Every claimed payload id must be unique — no message delivered twice.
|
||||
let claim_loop = |repo: PostgresGroupQueueRepo, group: GroupId| async move {
|
||||
let mut got: Vec<i64> = Vec::new();
|
||||
loop {
|
||||
match repo.claim(group, "tasks").await.unwrap() {
|
||||
Some(msg) => {
|
||||
got.push(msg.payload["i"].as_i64().unwrap());
|
||||
assert!(repo.ack(msg.id, msg.claim_token).await.unwrap(), "ack ok");
|
||||
}
|
||||
None => break,
|
||||
}
|
||||
}
|
||||
got
|
||||
};
|
||||
let (a, b, c, d) = tokio::join!(
|
||||
claim_loop(PostgresGroupQueueRepo::new(pool.clone()), group),
|
||||
claim_loop(PostgresGroupQueueRepo::new(pool.clone()), group),
|
||||
claim_loop(PostgresGroupQueueRepo::new(pool.clone()), group),
|
||||
claim_loop(PostgresGroupQueueRepo::new(pool.clone()), group),
|
||||
);
|
||||
|
||||
let mut all: Vec<i64> = Vec::new();
|
||||
all.extend(a);
|
||||
all.extend(b);
|
||||
all.extend(c);
|
||||
all.extend(d);
|
||||
let unique: HashSet<i64> = all.iter().copied().collect();
|
||||
assert_eq!(
|
||||
all.len(),
|
||||
N,
|
||||
"every message delivered exactly once (no dupes)"
|
||||
);
|
||||
assert_eq!(unique.len(), N, "all N distinct ids covered");
|
||||
assert_eq!(
|
||||
repo.depth(group, "tasks").await.unwrap(),
|
||||
0,
|
||||
"queue drained"
|
||||
);
|
||||
|
||||
// nack re-defers a message (a later claim gets it back).
|
||||
let id = repo
|
||||
.enqueue(NewGroupQueueMessage {
|
||||
group_id: group,
|
||||
collection: "tasks".into(),
|
||||
payload: serde_json::json!({ "i": 999 }),
|
||||
deliver_after: None,
|
||||
max_attempts: 3,
|
||||
enqueued_by_principal: None,
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
let msg = repo.claim(group, "tasks").await.unwrap().expect("claimed");
|
||||
assert_eq!(msg.id, id);
|
||||
repo.nack(msg.id, msg.claim_token, chrono::Duration::milliseconds(0))
|
||||
.await
|
||||
.unwrap();
|
||||
// After nack (0ms delay) it is claimable again.
|
||||
let again = repo.claim(group, "tasks").await.unwrap().expect("re-claim");
|
||||
assert_eq!(again.id, id, "nacked message is re-delivered");
|
||||
assert_eq!(again.attempt, 2, "attempt incremented on re-claim");
|
||||
repo.ack(again.id, again.claim_token).await.unwrap();
|
||||
|
||||
// Cleanup (messages cascade on group delete anyway).
|
||||
let _ = sqlx::query("DELETE FROM group_queue_messages WHERE group_id = $1")
|
||||
.bind(g.0)
|
||||
.execute(&pool)
|
||||
.await;
|
||||
let _ = sqlx::query("DELETE FROM groups WHERE id = $1")
|
||||
.bind(g.0)
|
||||
.execute(&pool)
|
||||
.await;
|
||||
}
|
||||
Reference in New Issue
Block a user