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>
This commit is contained in:
@@ -44,6 +44,10 @@ struct ShouldRow {
|
||||
effective_app_id: Uuid,
|
||||
template_id: Uuid,
|
||||
kind: String,
|
||||
/// §11.6 D3: a `shared` queue template's copies drain the GROUP store as
|
||||
/// COMPETING consumers, so the one-consumer-per-(app, queue) slot check is
|
||||
/// skipped for them (each descendant intentionally gets a consumer).
|
||||
shared: bool,
|
||||
}
|
||||
|
||||
/// Reconcile all materialized stateful-template copies. Idempotent; safe to call
|
||||
@@ -79,7 +83,7 @@ pub async fn rematerialize_stateful_templates(pool: &PgPool) -> Result<Vec<Strin
|
||||
FROM groups g JOIN app_chain ac ON g.id = ac.next_group \
|
||||
WHERE ac.depth < 64 AND g.parent_id IS NOT NULL \
|
||||
) \
|
||||
SELECT DISTINCT ac.effective_app_id, t.id AS template_id, t.kind \
|
||||
SELECT DISTINCT ac.effective_app_id, t.id AS template_id, t.kind, t.shared \
|
||||
FROM app_chain ac \
|
||||
JOIN triggers t ON t.group_id = ac.owner_group \
|
||||
WHERE t.group_id IS NOT NULL AND t.enabled = TRUE AND t.kind = ANY($1)",
|
||||
@@ -118,8 +122,14 @@ pub async fn rematerialize_stateful_templates(pool: &PgPool) -> Result<Vec<Strin
|
||||
if existing_set.contains(&(r.effective_app_id, r.template_id)) {
|
||||
continue;
|
||||
}
|
||||
if let Some(w) =
|
||||
materialize_one(&mut tx, r.effective_app_id, r.template_id, &r.kind).await?
|
||||
if let Some(w) = materialize_one(
|
||||
&mut tx,
|
||||
r.effective_app_id,
|
||||
r.template_id,
|
||||
&r.kind,
|
||||
r.shared,
|
||||
)
|
||||
.await?
|
||||
{
|
||||
warnings.push(w);
|
||||
}
|
||||
@@ -138,11 +148,14 @@ async fn materialize_one(
|
||||
app_id: Uuid,
|
||||
template_id: Uuid,
|
||||
kind: &str,
|
||||
shared: bool,
|
||||
) -> Result<Option<String>, sqlx::Error> {
|
||||
// §M5.4: a queue copy would violate the one-consumer-per-(app_id, queue_name)
|
||||
// invariant if the app already has a consumer (hand-authored or another
|
||||
// template) on that queue — skip with a warning.
|
||||
if kind == "queue" {
|
||||
// §M5.4: a per-app queue copy would violate the one-consumer-per-(app_id,
|
||||
// queue_name) invariant if the app already has a consumer on that queue —
|
||||
// skip with a warning. §11.6 D3: a SHARED queue copy drains the group store
|
||||
// as a competing consumer (a different store), so the slot check is skipped
|
||||
// — every descendant intentionally gets a consumer.
|
||||
if kind == "queue" && !shared {
|
||||
let taken: Option<(Uuid,)> = sqlx::query_as(
|
||||
"SELECT t.id FROM triggers t \
|
||||
JOIN queue_trigger_details d ON d.trigger_id = t.id \
|
||||
|
||||
Reference in New Issue
Block a user