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:
MechaCat02
2026-07-02 22:33:11 +02:00
parent ccd3644aa4
commit 50205e7b7e
8 changed files with 299 additions and 76 deletions

View File

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