Files
PiCloud/crates/manager-core/tests/group_queue.rs
MechaCat02 50205e7b7e feat(shared-queues): materialized competing consumers + dispatcher branch (D3.2)
The consumption side of shared durable queues:
- Thread `shared` through BundleTrigger::Queue + QueueTriggerSpec + the
  trigger identity; validate_bundle_for requires a shared queue on a group
  to name a declared kind='queue' collection (a shared queue on an app is
  rejected by the existing app-owner shared guard).
- materialize: a shared queue template materializes a consumer per
  descendant (the M5 one-consumer-slot skip is bypassed for shared —
  competing consumers are intended; each descendant gets one copy).
- dispatcher: ActiveQueueConsumer gains shared_group (from the materialized
  copy's source template via LEFT JOIN); dispatch_one_queue +
  handle_queue_failure route claim/ack/nack/terminal to the group store
  when shared_group is Some, via q_claim/q_ack/q_nack/q_terminal helpers. A
  group claim is normalized to a ClaimedMessage under the consuming app so
  the handler path is unchanged; the reclaim task also drains the group
  store. Exhausted shared messages are dropped (no group dead-letter store
  yet — documented deferral).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-02 22:33:11 +02:00

137 lines
4.7 KiB
Rust

//! §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, clippy::many_single_char_names)]
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.
let n_msgs: usize = 50;
for i in 0..n_msgs {
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();
while let Some(msg) = repo.claim(group, "tasks").await.unwrap() {
got.push(msg.payload["i"].as_i64().unwrap());
assert!(repo.ack(msg.id, msg.claim_token).await.unwrap(), "ack ok");
}
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_msgs,
"every message delivered exactly once (no dupes)"
);
assert_eq!(unique.len(), n_msgs, "all 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;
}