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

@@ -337,6 +337,11 @@ pub struct ActiveQueueConsumer {
pub retry_backoff: BackoffShape,
pub retry_base_ms: u32,
pub registered_by_principal: AdminUserId,
/// §11.6 D3: `Some(group)` when this consumer is a materialized copy of a
/// SHARED group queue template — it drains `group_queue_messages(group,
/// queue_name)` (competing consumers) instead of the per-app store. `None`
/// for an ordinary per-app or non-shared-template consumer.
pub shared_group: Option<GroupId>,
}
/// What the inbound-email webhook receiver needs to verify + dispatch a
@@ -1792,13 +1797,19 @@ impl TriggerRepo for PostgresTriggerRepo {
&self,
) -> Result<Vec<ActiveQueueConsumer>, TriggerRepoError> {
let rows: Vec<QueueConsumerRow> = sqlx::query_as(
// §11.6 D3: LEFT JOIN the SOURCE template of a materialized copy; if
// that template is a SHARED group queue, `shared_group` is its
// owning group and this consumer drains the group store instead of
// the per-app one.
"SELECT t.id AS trigger_id, t.app_id, t.script_id, d.queue_name, \
d.visibility_timeout_secs, \
t.retry_max_attempts, t.retry_backoff, t.retry_base_ms, \
t.registered_by_principal \
t.registered_by_principal, tmpl.group_id AS shared_group \
FROM triggers t \
JOIN queue_trigger_details d ON d.trigger_id = t.id \
JOIN scripts s ON s.id = t.script_id \
LEFT JOIN triggers tmpl \
ON tmpl.id = t.materialized_from AND tmpl.shared = TRUE \
WHERE t.kind = 'queue' AND t.enabled = TRUE AND s.enabled = TRUE \
AND t.app_id IS NOT NULL",
)
@@ -1817,6 +1828,7 @@ impl TriggerRepo for PostgresTriggerRepo {
.unwrap_or(BackoffShape::Exponential),
retry_base_ms: u32::try_from(r.retry_base_ms).unwrap_or(1000),
registered_by_principal: r.registered_by_principal.into(),
shared_group: r.shared_group.map(Into::into),
})
.collect())
}
@@ -2096,6 +2108,9 @@ struct QueueConsumerRow {
retry_backoff: String,
retry_base_ms: i32,
registered_by_principal: Uuid,
// §11.6 D3: the owning group of a SHARED queue template this consumer was
// materialized from; NULL for a per-app / non-shared consumer.
shared_group: Option<Uuid>,
}
#[derive(sqlx::FromRow)]