feat(triggers): shared dead-letter trigger fan-out (B2)
A group declares a declaratively-authored [[triggers.dead_letter]] shared=true handler; when a message in the group's SHARED queue is exhausted, the dispatcher's q_terminal group branch (after persisting to group_dead_letters) fans out to it via list_matching_shared_dead_letter(owning_group, "queue", …), each outbox row stamped the WRITER app_id (the consuming app — the M2 shared -write model), so the handler runs under the consumer. The per-app list_matching_dead_letter gained AND t.shared = FALSE (the shared flag is the namespace boundary); the owning-group filter is the isolation boundary. Adds BundleTrigger::DeadLetter + a DeadLetterTriggerSpec manifest kind (group +shared only — validate_bundle_for rejects app-owned or non-shared, and exempts it from the shared-requires-a-collection rule); insert_trigger_tx now accepts dead_letter and writes dead_letter_trigger_details; current_trigger_identity matches a group-shared dead_letter so re-apply is a NoOp (app-owned ones stay diff-invisible). Pinned by tests/shared_dead_letter.rs (owning group matches, per-app query does not, foreign group does not). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -294,6 +294,23 @@ pub enum BundleTrigger {
|
||||
#[serde(default)]
|
||||
shared: bool,
|
||||
},
|
||||
/// §11.6 B2: a dead-letter handler. Declarative authoring is restricted to
|
||||
/// GROUP-owned + `shared = true` — it fires when a message in the group's
|
||||
/// SHARED queue is exhausted (dead-lettered). An app-owned or non-shared
|
||||
/// `dead_letter` bundle trigger is rejected in `validate_bundle_for`.
|
||||
DeadLetter {
|
||||
script: String,
|
||||
/// Match only dead-letters filed under this source (`"queue"` for a
|
||||
/// shared-queue exhaustion). `None` matches any source.
|
||||
#[serde(default)]
|
||||
source_filter: Option<String>,
|
||||
#[serde(default)]
|
||||
dispatch_mode: Option<TriggerDispatchMode>,
|
||||
#[serde(default)]
|
||||
retry_max_attempts: Option<u32>,
|
||||
#[serde(default)]
|
||||
shared: bool,
|
||||
},
|
||||
}
|
||||
|
||||
fn default_timezone() -> String {
|
||||
@@ -311,7 +328,8 @@ impl BundleTrigger {
|
||||
| Self::Cron { script, .. }
|
||||
| Self::Pubsub { script, .. }
|
||||
| Self::Email { script, .. }
|
||||
| Self::Queue { script, .. } => script,
|
||||
| Self::Queue { script, .. }
|
||||
| Self::DeadLetter { script, .. } => script,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -332,7 +350,10 @@ impl BundleTrigger {
|
||||
| Self::Docs { sealed, .. }
|
||||
| Self::Files { sealed, .. }
|
||||
| Self::Pubsub { sealed, .. } => *sealed,
|
||||
Self::Cron { .. } | Self::Email { .. } | Self::Queue { .. } => false,
|
||||
Self::Cron { .. }
|
||||
| Self::Email { .. }
|
||||
| Self::Queue { .. }
|
||||
| Self::DeadLetter { .. } => false,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -347,7 +368,8 @@ impl BundleTrigger {
|
||||
| Self::Docs { shared, .. }
|
||||
| Self::Files { shared, .. }
|
||||
| Self::Pubsub { shared, .. }
|
||||
| Self::Queue { shared, .. } => *shared,
|
||||
| Self::Queue { shared, .. }
|
||||
| Self::DeadLetter { shared, .. } => *shared,
|
||||
Self::Cron { .. } | Self::Email { .. } => false,
|
||||
}
|
||||
}
|
||||
@@ -427,6 +449,17 @@ impl BundleTrigger {
|
||||
Self::Queue {
|
||||
queue_name, shared, ..
|
||||
} => format!("queue|{queue_name}|{shared}"),
|
||||
// §11.6 B2: mirrored by `current_trigger_identity` for a group-owned
|
||||
// shared dead_letter, so a re-apply diffs as NoOp.
|
||||
Self::DeadLetter {
|
||||
script,
|
||||
source_filter,
|
||||
shared,
|
||||
..
|
||||
} => format!(
|
||||
"dead_letter|{script}|{}|{shared}",
|
||||
source_filter.as_deref().unwrap_or("")
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -440,6 +473,7 @@ impl BundleTrigger {
|
||||
Self::Pubsub { .. } => "pubsub",
|
||||
Self::Email { .. } => "email",
|
||||
Self::Queue { .. } => "queue",
|
||||
Self::DeadLetter { .. } => "dead_letter",
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1117,6 +1151,11 @@ impl ApplyService {
|
||||
(topic_pattern.split('.').next().unwrap_or(""), "topic")
|
||||
}
|
||||
BundleTrigger::Queue { queue_name, .. } => (queue_name.as_str(), "queue"),
|
||||
// §11.6 B2: a shared dead_letter watches the group's
|
||||
// shared queue exhaustion, not a named collection store —
|
||||
// no declared-collection requirement. The group/shared
|
||||
// gate for it lives below.
|
||||
BundleTrigger::DeadLetter { .. } => continue,
|
||||
_ => {
|
||||
return Err(ApplyError::Invalid(format!(
|
||||
"a `shared` trigger must be a kv/docs/files/pubsub/queue kind; \
|
||||
@@ -1138,6 +1177,17 @@ impl ApplyService {
|
||||
)));
|
||||
}
|
||||
}
|
||||
// §11.6 B2: a group `dead_letter` template is only meaningful as
|
||||
// `shared = true` — it fires on the group's SHARED queue
|
||||
// exhaustion. A non-shared group dead_letter would never fire (no
|
||||
// per-app queue to watch at the group level), so reject it.
|
||||
if matches!(t, BundleTrigger::DeadLetter { .. }) && !t.shared() {
|
||||
return Err(ApplyError::Invalid(
|
||||
"a group `dead_letter` trigger must be `shared = true` — it \
|
||||
fires on the group's shared-queue exhaustion"
|
||||
.into(),
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
// §11.6: shared collections are owned by GROUPS. Reject them on an app
|
||||
@@ -1182,6 +1232,21 @@ impl ApplyService {
|
||||
declares the collection",
|
||||
));
|
||||
}
|
||||
// §11.6 B2: a `dead_letter` trigger is authored only as a group-owned
|
||||
// shared template (it watches the group's shared-queue exhaustion).
|
||||
// An app-owned dead_letter handler is created via `pic triggers`, not
|
||||
// the declarative manifest.
|
||||
if bundle
|
||||
.triggers
|
||||
.iter()
|
||||
.any(|t| matches!(t, BundleTrigger::DeadLetter { .. }))
|
||||
{
|
||||
return Err(app_only_reject(
|
||||
"trigger cannot be a `dead_letter` kind",
|
||||
"a declarative dead_letter is a group-owned shared template; \
|
||||
create an app dead-letter handler with `pic triggers`",
|
||||
));
|
||||
}
|
||||
}
|
||||
// §11 tail M1: both an app and a group may declare suppressions — an
|
||||
// app declines an inherited template for itself, a group for its whole
|
||||
@@ -3313,6 +3378,11 @@ impl ApplyService {
|
||||
dispatch_mode,
|
||||
retry_max_attempts,
|
||||
..
|
||||
}
|
||||
| BundleTrigger::DeadLetter {
|
||||
dispatch_mode,
|
||||
retry_max_attempts,
|
||||
..
|
||||
} => (
|
||||
dispatch_mode.unwrap_or(TriggerDispatchMode::Async),
|
||||
retry_max_attempts.unwrap_or(self.trigger_config.retry_max_attempts),
|
||||
@@ -5347,7 +5417,22 @@ fn current_trigger_identity(t: &Trigger, name_by_id: &HashMap<ScriptId, String>)
|
||||
}
|
||||
TriggerDetails::Email { .. } => Some(format!("email|{script}")),
|
||||
TriggerDetails::Queue { queue_name, .. } => Some(format!("queue|{queue_name}|{shared}")),
|
||||
TriggerDetails::DeadLetter { .. } => None,
|
||||
// §11.6 B2: a GROUP-owned shared dead_letter is declarative (a `[group]`
|
||||
// template), so it participates in the diff like the other group
|
||||
// templates — its identity mirrors `BundleTrigger::identity`. An
|
||||
// APP-owned dead_letter (interactive API, not manifest-representable)
|
||||
// stays `None`, so the diff neither matches nor prunes it (same as
|
||||
// email).
|
||||
TriggerDetails::DeadLetter { source_filter, .. } => {
|
||||
if t.group_id.is_some() && shared {
|
||||
Some(format!(
|
||||
"dead_letter|{script}|{}|{shared}",
|
||||
source_filter.as_deref().unwrap_or("")
|
||||
))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5794,6 +5879,11 @@ fn bundle_trigger_details(bt: &BundleTrigger, default_visibility: u32) -> Trigge
|
||||
visibility_timeout_secs: visibility_timeout_secs.unwrap_or(default_visibility),
|
||||
last_fired_at: None,
|
||||
},
|
||||
BundleTrigger::DeadLetter { source_filter, .. } => TriggerDetails::DeadLetter {
|
||||
source_filter: source_filter.clone(),
|
||||
trigger_id_filter: None,
|
||||
script_id_filter: None,
|
||||
},
|
||||
BundleTrigger::Email { .. } => unreachable!("email handled separately"),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -456,10 +456,14 @@ impl Dispatcher {
|
||||
// §11.6 D3: persist the exhausted message to the group dead-letter
|
||||
// store instead of dropping it. We return None (not the dl id) so
|
||||
// the per-app `fan_out_dead_letter` below is SKIPPED — firing the
|
||||
// consuming app's per-app dead_letter handlers on a shared-queue
|
||||
// consuming app's *per-app* dead_letter handlers on a shared-queue
|
||||
// message (competing consumers → nondeterministic app) would be
|
||||
// wrong. Fan-out to a *shared* dead_letter trigger is deferred; the
|
||||
// row is operator-visible via the group dead-letters admin API.
|
||||
// wrong.
|
||||
//
|
||||
// §11.6 B2: instead, fan out to the group's *shared* dead_letter
|
||||
// handlers (`shared = true` on the owning group). Each runs under
|
||||
// the WRITER app (`claimed.app_id`, the consumer that exhausted
|
||||
// the message) — the M2 shared-write model.
|
||||
match self
|
||||
.group_queue
|
||||
.dead_letter(
|
||||
@@ -475,12 +479,42 @@ impl Dispatcher {
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(dl_id) => tracing::warn!(
|
||||
reason,
|
||||
queue = %claimed.queue_name,
|
||||
dead_letter_id = %dl_id.into_inner(),
|
||||
"shared-queue message dead-lettered"
|
||||
),
|
||||
Ok(dl_id) => {
|
||||
tracing::warn!(
|
||||
reason,
|
||||
queue = %claimed.queue_name,
|
||||
dead_letter_id = %dl_id.into_inner(),
|
||||
"shared-queue message dead-lettered"
|
||||
);
|
||||
let original = TriggerEvent::Queue {
|
||||
queue_name: claimed.queue_name.clone(),
|
||||
message: claimed.payload.clone(),
|
||||
enqueued_at: claimed.enqueued_at,
|
||||
attempt: claimed.attempt,
|
||||
message_id: claimed.id.to_string(),
|
||||
};
|
||||
self.fan_out_shared_dead_letter(
|
||||
group_id,
|
||||
DeadLetterFanOutCtx {
|
||||
// Writer app: the consumer that exhausted the msg.
|
||||
app_id: claimed.app_id,
|
||||
original,
|
||||
source: "queue".to_string(),
|
||||
dead_letter_id: dl_id,
|
||||
attempts: claimed.attempt,
|
||||
last_error: reason.to_string(),
|
||||
trigger_id,
|
||||
script_id,
|
||||
first_attempt_at: claimed.enqueued_at,
|
||||
last_attempt_at: Utc::now(),
|
||||
// Shared-queue messages root a depth-1 chain (the
|
||||
// queue is depth 0; a DL handler ticks up).
|
||||
trigger_depth: 1,
|
||||
root_execution_id: None,
|
||||
},
|
||||
)
|
||||
.await;
|
||||
}
|
||||
Err(e) => tracing::error!(?e, "shared-queue dead-letter write failed"),
|
||||
}
|
||||
None
|
||||
@@ -1499,6 +1533,83 @@ impl Dispatcher {
|
||||
}
|
||||
}
|
||||
|
||||
/// §11.6 B2: the shared analogue of `fan_out_dead_letter`. When a message in
|
||||
/// a group's SHARED queue is exhausted, fire the group's `shared = true`
|
||||
/// `dead_letter` handlers. Each outbox row is stamped `ctx.app_id` (the
|
||||
/// writer/consumer that exhausted the message) so the handler runs under
|
||||
/// that app's `SdkCallCx` — the M2 shared-write model. Best-effort, mirroring
|
||||
/// the per-app path (the group dead-letter row is already durably written).
|
||||
async fn fan_out_shared_dead_letter(
|
||||
&self,
|
||||
owning_group: picloud_shared::GroupId,
|
||||
ctx: DeadLetterFanOutCtx,
|
||||
) {
|
||||
let DeadLetterFanOutCtx {
|
||||
app_id,
|
||||
original,
|
||||
source,
|
||||
dead_letter_id,
|
||||
attempts,
|
||||
last_error,
|
||||
trigger_id,
|
||||
script_id,
|
||||
first_attempt_at,
|
||||
last_attempt_at,
|
||||
trigger_depth,
|
||||
root_execution_id,
|
||||
} = ctx;
|
||||
|
||||
let matches = match self
|
||||
.triggers
|
||||
.list_matching_shared_dead_letter(owning_group, &source, trigger_id, script_id)
|
||||
.await
|
||||
{
|
||||
Ok(m) => m,
|
||||
Err(e) => {
|
||||
tracing::error!(?e, "shared dead-letter trigger lookup failed");
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
for m in matches {
|
||||
let event = TriggerEvent::DeadLetter {
|
||||
dead_letter_id,
|
||||
original: Box::new(original.clone()),
|
||||
attempts,
|
||||
last_error: last_error.clone(),
|
||||
trigger_id,
|
||||
script_id,
|
||||
first_attempt_at,
|
||||
last_attempt_at,
|
||||
};
|
||||
let payload = match serde_json::to_value(&event) {
|
||||
Ok(p) => p,
|
||||
Err(e) => {
|
||||
tracing::error!(?e, "failed to serialize shared dead-letter event");
|
||||
continue;
|
||||
}
|
||||
};
|
||||
if let Err(e) = self
|
||||
.outbox
|
||||
.insert(NewOutboxRow {
|
||||
// Writer app — the consumer that exhausted the message.
|
||||
app_id,
|
||||
source_kind: OutboxSourceKind::DeadLetter,
|
||||
trigger_id: Some(m.trigger_id),
|
||||
script_id: Some(m.script_id),
|
||||
reply_to: None,
|
||||
payload,
|
||||
origin_principal: Some(m.registered_by_principal),
|
||||
trigger_depth: trigger_depth.saturating_add(1),
|
||||
root_execution_id,
|
||||
})
|
||||
.await
|
||||
{
|
||||
tracing::error!(?e, "failed to enqueue shared dead-letter handler delivery");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn deliver_inbox(&self, row: &OutboxRow, inbox_id: Uuid, result: InboxResult) {
|
||||
match self.inbox.deliver(inbox_id, result.clone()).await {
|
||||
InboxDeliveryOutcome::Delivered => {}
|
||||
|
||||
@@ -563,6 +563,23 @@ pub trait TriggerRepo: Send + Sync {
|
||||
script_id: Option<ScriptId>,
|
||||
) -> Result<Vec<DeadLetterTriggerMatch>, TriggerRepoError>;
|
||||
|
||||
/// §11.6 B2 shared dead-letter fan-out: enabled `shared = true`
|
||||
/// `dead_letter` triggers on the OWNING group — fired when a message in
|
||||
/// that group's SHARED queue is dead-lettered. Same source/trigger/script
|
||||
/// filter logic as `list_matching_dead_letter`, but keyed on the owning
|
||||
/// group instead of a chain walk. Default empty so non-Postgres impls
|
||||
/// degrade to "no shared dead-letter triggers".
|
||||
async fn list_matching_shared_dead_letter(
|
||||
&self,
|
||||
owning_group: GroupId,
|
||||
source: &str,
|
||||
trigger_id: Option<TriggerId>,
|
||||
script_id: Option<ScriptId>,
|
||||
) -> Result<Vec<DeadLetterTriggerMatch>, TriggerRepoError> {
|
||||
let _ = (owning_group, source, trigger_id, script_id);
|
||||
Ok(Vec::new())
|
||||
}
|
||||
|
||||
/// v1.1.9. Create a queue:receive trigger. Enforces exactly one
|
||||
/// consumer per `(app_id, queue_name)` via `pg_advisory_xact_lock`
|
||||
/// + SELECT-then-INSERT (a partial unique index across the parent
|
||||
@@ -725,7 +742,9 @@ pub(crate) async fn insert_trigger_tx(
|
||||
TriggerDetails::Cron { .. } => "cron",
|
||||
TriggerDetails::Pubsub { .. } => "pubsub",
|
||||
TriggerDetails::Queue { .. } => "queue",
|
||||
TriggerDetails::DeadLetter { .. } | TriggerDetails::Email { .. } => {
|
||||
// §11.6 B2: a group-owned, shared dead_letter template is declarative.
|
||||
TriggerDetails::DeadLetter { .. } => "dead_letter",
|
||||
TriggerDetails::Email { .. } => {
|
||||
return Err(TriggerRepoError::Invalid(
|
||||
"trigger kind not supported by declarative apply".into(),
|
||||
));
|
||||
@@ -870,7 +889,27 @@ pub(crate) async fn insert_trigger_tx(
|
||||
.execute(&mut **tx)
|
||||
.await?;
|
||||
}
|
||||
TriggerDetails::DeadLetter { .. } | TriggerDetails::Email { .. } => {
|
||||
// §11.6 B2: mirror `create_dead_letter_trigger`'s detail insert. For the
|
||||
// declarative (bundle) path the trigger_id/script_id filters are always
|
||||
// None — a group shared dead_letter matches by source only.
|
||||
TriggerDetails::DeadLetter {
|
||||
source_filter,
|
||||
trigger_id_filter,
|
||||
script_id_filter,
|
||||
} => {
|
||||
sqlx::query(
|
||||
"INSERT INTO dead_letter_trigger_details \
|
||||
(trigger_id, source_filter, trigger_id_filter, script_id_filter) \
|
||||
VALUES ($1, $2, $3, $4)",
|
||||
)
|
||||
.bind(tid)
|
||||
.bind(source_filter.as_deref())
|
||||
.bind(trigger_id_filter.map(TriggerId::into_inner))
|
||||
.bind(script_id_filter.map(ScriptId::into_inner))
|
||||
.execute(&mut **tx)
|
||||
.await?;
|
||||
}
|
||||
TriggerDetails::Email { .. } => {
|
||||
unreachable!("guarded above")
|
||||
}
|
||||
}
|
||||
@@ -1654,6 +1693,7 @@ impl TriggerRepo for PostgresTriggerRepo {
|
||||
FROM triggers t \
|
||||
JOIN dead_letter_trigger_details d ON d.trigger_id = t.id \
|
||||
WHERE t.app_id = $1 AND t.kind = 'dead_letter' AND t.enabled = TRUE \
|
||||
AND t.shared = FALSE \
|
||||
AND (d.source_filter IS NULL OR d.source_filter = $2) \
|
||||
AND (d.trigger_id_filter IS NULL OR d.trigger_id_filter = $3) \
|
||||
AND (d.script_id_filter IS NULL OR d.script_id_filter = $4)",
|
||||
@@ -1676,6 +1716,42 @@ impl TriggerRepo for PostgresTriggerRepo {
|
||||
.collect())
|
||||
}
|
||||
|
||||
async fn list_matching_shared_dead_letter(
|
||||
&self,
|
||||
owning_group: GroupId,
|
||||
source: &str,
|
||||
trigger_id: Option<TriggerId>,
|
||||
script_id: Option<ScriptId>,
|
||||
) -> Result<Vec<DeadLetterTriggerMatch>, TriggerRepoError> {
|
||||
let rows: Vec<DlMatchRow> = sqlx::query_as(
|
||||
"SELECT t.id, t.script_id, t.dispatch_mode, t.registered_by_principal, \
|
||||
d.source_filter, d.trigger_id_filter, d.script_id_filter \
|
||||
FROM triggers t \
|
||||
JOIN dead_letter_trigger_details d ON d.trigger_id = t.id \
|
||||
WHERE t.group_id = $1 AND t.kind = 'dead_letter' AND t.enabled = TRUE \
|
||||
AND t.shared = TRUE \
|
||||
AND (d.source_filter IS NULL OR d.source_filter = $2) \
|
||||
AND (d.trigger_id_filter IS NULL OR d.trigger_id_filter = $3) \
|
||||
AND (d.script_id_filter IS NULL OR d.script_id_filter = $4)",
|
||||
)
|
||||
.bind(owning_group.into_inner())
|
||||
.bind(source)
|
||||
.bind(trigger_id.map(TriggerId::into_inner))
|
||||
.bind(script_id.map(ScriptId::into_inner))
|
||||
.fetch_all(&self.pool)
|
||||
.await?;
|
||||
|
||||
Ok(rows
|
||||
.into_iter()
|
||||
.map(|r| DeadLetterTriggerMatch {
|
||||
trigger_id: r.id.into(),
|
||||
script_id: r.script_id.into(),
|
||||
dispatch_mode: dispatch_from_str(&r.dispatch_mode),
|
||||
registered_by_principal: r.registered_by_principal.into(),
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
|
||||
async fn create_queue_trigger(
|
||||
&self,
|
||||
app_id: AppId,
|
||||
|
||||
177
crates/manager-core/tests/shared_dead_letter.rs
Normal file
177
crates/manager-core/tests/shared_dead_letter.rs
Normal file
@@ -0,0 +1,177 @@
|
||||
//! §11.6 B2 integration test: SHARED dead-letter triggers.
|
||||
//! A group-owned `dead_letter` trigger marked `shared = true` fires when a
|
||||
//! message in that group's SHARED queue is exhausted. It matches via the
|
||||
//! OWNING-group query (`list_matching_shared_dead_letter`); a descendant app's
|
||||
//! per-app dead-letter query (`list_matching_dead_letter`) does NOT match it
|
||||
//! (the `shared` flag is the namespace boundary), and a foreign sibling group
|
||||
//! does NOT match it (the owning-group filter is the isolation boundary).
|
||||
//!
|
||||
//! Deterministic: drives the repo match queries directly (no async dispatcher).
|
||||
//! Skips when `DATABASE_URL` is unset.
|
||||
|
||||
#![allow(clippy::needless_pass_by_value, clippy::too_many_lines)]
|
||||
|
||||
use picloud_manager_core::trigger_repo::{PostgresTriggerRepo, TriggerRepo};
|
||||
use picloud_shared::{AppId, 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 {
|
||||
picloud_test_support::abort_if_db_required("shared_dead_letter");
|
||||
eprintln!("shared_dead_letter: DATABASE_URL unset — skipping");
|
||||
return None;
|
||||
};
|
||||
let pool = PgPoolOptions::new()
|
||||
.max_connections(2)
|
||||
.connect(&url)
|
||||
.await
|
||||
.expect("connect");
|
||||
sqlx::migrate!("./migrations")
|
||||
.run(&pool)
|
||||
.await
|
||||
.expect("migrate");
|
||||
Some(pool)
|
||||
}
|
||||
|
||||
/// Insert a group-owned dead_letter trigger (`shared` flag) + its details.
|
||||
async fn dead_letter_trigger(
|
||||
pool: &PgPool,
|
||||
group_id: Uuid,
|
||||
script: Uuid,
|
||||
admin: Uuid,
|
||||
shared: bool,
|
||||
) -> Uuid {
|
||||
let row: (Uuid,) = sqlx::query_as(
|
||||
"INSERT INTO triggers \
|
||||
(app_id, group_id, script_id, kind, enabled, dispatch_mode, \
|
||||
retry_max_attempts, retry_backoff, retry_base_ms, \
|
||||
registered_by_principal, name, shared) \
|
||||
VALUES (NULL, $1, $2, 'dead_letter', TRUE, 'async', 1, 'constant', 0, $3, $4, $5) \
|
||||
RETURNING id",
|
||||
)
|
||||
.bind(group_id)
|
||||
.bind(script)
|
||||
.bind(admin)
|
||||
.bind(Uuid::new_v4().simple().to_string())
|
||||
.bind(shared)
|
||||
.fetch_one(pool)
|
||||
.await
|
||||
.expect("trigger");
|
||||
sqlx::query(
|
||||
"INSERT INTO dead_letter_trigger_details \
|
||||
(trigger_id, source_filter, trigger_id_filter, script_id_filter) \
|
||||
VALUES ($1, NULL, NULL, NULL)",
|
||||
)
|
||||
.bind(row.0)
|
||||
.execute(pool)
|
||||
.await
|
||||
.expect("details");
|
||||
row.0
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn shared_dead_letter_matches_only_owning_group() {
|
||||
let Some(pool) = pool_or_skip().await else {
|
||||
return;
|
||||
};
|
||||
let sfx = Uuid::new_v4().simple().to_string();
|
||||
let admin = {
|
||||
let r: (Uuid,) = sqlx::query_as(
|
||||
"INSERT INTO admin_users (username, password_hash) VALUES ($1, 'x') RETURNING id",
|
||||
)
|
||||
.bind(format!("dl-{sfx}"))
|
||||
.fetch_one(&pool)
|
||||
.await
|
||||
.unwrap();
|
||||
r.0
|
||||
};
|
||||
|
||||
// Group G with a group-owned handler + a SHARED dead_letter trigger.
|
||||
let g: (Uuid,) = sqlx::query_as("INSERT INTO groups (slug, name) VALUES ($1, $1) RETURNING id")
|
||||
.bind(format!("dl-g-{sfx}"))
|
||||
.fetch_one(&pool)
|
||||
.await
|
||||
.unwrap();
|
||||
let handler: (Uuid,) = sqlx::query_as(
|
||||
"INSERT INTO scripts (name, source, group_id) VALUES ($1, 'x', $2) RETURNING id",
|
||||
)
|
||||
.bind(format!("on-dl-{sfx}"))
|
||||
.bind(g.0)
|
||||
.fetch_one(&pool)
|
||||
.await
|
||||
.unwrap();
|
||||
let shared_trig = dead_letter_trigger(&pool, g.0, handler.0, admin, true).await;
|
||||
|
||||
// Descendant app A under G.
|
||||
let a: (Uuid,) =
|
||||
sqlx::query_as("INSERT INTO apps (slug, name, group_id) VALUES ($1, $1, $2) RETURNING id")
|
||||
.bind(format!("dl-a-{sfx}"))
|
||||
.bind(g.0)
|
||||
.fetch_one(&pool)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// Sibling group S (foreign — not an ancestor of A).
|
||||
let s: (Uuid,) = sqlx::query_as("INSERT INTO groups (slug, name) VALUES ($1, $1) RETURNING id")
|
||||
.bind(format!("dl-s-{sfx}"))
|
||||
.fetch_one(&pool)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let trig = PostgresTriggerRepo::new(pool.clone());
|
||||
|
||||
// 1. The owning group G's shared query returns the trigger.
|
||||
let g_matches = trig
|
||||
.list_matching_shared_dead_letter(GroupId::from(g.0), "queue", None, None)
|
||||
.await
|
||||
.expect("shared match");
|
||||
assert!(
|
||||
g_matches.iter().any(|m| m.trigger_id == shared_trig.into()),
|
||||
"the owning group's shared dead-letter query must match its shared trigger"
|
||||
);
|
||||
|
||||
// 2. The per-app query on descendant A returns NOTHING — a shared group
|
||||
// template must not match the per-app (`shared = FALSE`) path.
|
||||
let a_matches = trig
|
||||
.list_matching_dead_letter(AppId::from(a.0), "queue", None, None)
|
||||
.await
|
||||
.expect("app match");
|
||||
assert!(
|
||||
!a_matches.iter().any(|m| m.trigger_id == shared_trig.into()),
|
||||
"a per-app dead-letter query must NOT match the group's shared trigger"
|
||||
);
|
||||
|
||||
// 3. A foreign sibling group S returns NOTHING (owning-group isolation).
|
||||
let s_matches = trig
|
||||
.list_matching_shared_dead_letter(GroupId::from(s.0), "queue", None, None)
|
||||
.await
|
||||
.expect("foreign match");
|
||||
assert!(
|
||||
!s_matches.iter().any(|m| m.trigger_id == shared_trig.into()),
|
||||
"a foreign group's shared dead-letter query must NOT match another group's trigger"
|
||||
);
|
||||
|
||||
// Cleanup.
|
||||
let _ = sqlx::query("DELETE FROM triggers WHERE id = $1")
|
||||
.bind(shared_trig)
|
||||
.execute(&pool)
|
||||
.await;
|
||||
let _ = sqlx::query("DELETE FROM apps WHERE id = $1")
|
||||
.bind(a.0)
|
||||
.execute(&pool)
|
||||
.await;
|
||||
let _ = sqlx::query("DELETE FROM scripts WHERE id = $1")
|
||||
.bind(handler.0)
|
||||
.execute(&pool)
|
||||
.await;
|
||||
let _ = sqlx::query("DELETE FROM groups WHERE id = ANY($1)")
|
||||
.bind(vec![g.0, s.0])
|
||||
.execute(&pool)
|
||||
.await;
|
||||
let _ = sqlx::query("DELETE FROM admin_users WHERE id = $1")
|
||||
.bind(admin)
|
||||
.execute(&pool)
|
||||
.await;
|
||||
}
|
||||
@@ -223,6 +223,9 @@ pub fn build_bundle(manifest: &Manifest, base_dir: &Path) -> Result<Value> {
|
||||
for s in &t.queue {
|
||||
triggers.push(tagged("queue", s)?);
|
||||
}
|
||||
for s in &t.dead_letter {
|
||||
triggers.push(tagged("dead_letter", s)?);
|
||||
}
|
||||
|
||||
// Vars: key → JSON value. TOML values round-trip to JSON via serde so the
|
||||
// wire shape matches the server's `serde_json::Value`.
|
||||
|
||||
@@ -468,6 +468,8 @@ pub struct ManifestTriggers {
|
||||
pub email: Vec<EmailTriggerSpec>,
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub queue: Vec<QueueTriggerSpec>,
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub dead_letter: Vec<DeadLetterTriggerSpec>,
|
||||
}
|
||||
|
||||
impl ManifestTriggers {
|
||||
@@ -480,6 +482,7 @@ impl ManifestTriggers {
|
||||
&& self.pubsub.is_empty()
|
||||
&& self.email.is_empty()
|
||||
&& self.queue.is_empty()
|
||||
&& self.dead_letter.is_empty()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -606,6 +609,26 @@ pub struct QueueTriggerSpec {
|
||||
pub shared: bool,
|
||||
}
|
||||
|
||||
/// §11.6 B2: a `[[triggers.dead_letter]]` handler. Declarative authoring is
|
||||
/// restricted to GROUP-owned + `shared = true` — it fires when a message in the
|
||||
/// group's SHARED queue is exhausted. `source_filter` narrows to a dead-letter
|
||||
/// source (`"queue"`); omitted matches any.
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct DeadLetterTriggerSpec {
|
||||
pub script: String,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub source_filter: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub dispatch_mode: Option<DispatchMode>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub retry_max_attempts: Option<u32>,
|
||||
/// Must be `true` — a declarative dead_letter is a group-owned shared
|
||||
/// template. The server rejects a non-shared or app-owned one.
|
||||
#[serde(default, skip_serializing_if = "is_false")]
|
||||
pub shared: bool,
|
||||
}
|
||||
|
||||
/// `[secrets] names = [...]` — declares which secrets the app expects.
|
||||
/// Values are never in the manifest; `pic secret set` pushes them.
|
||||
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
|
||||
|
||||
Reference in New Issue
Block a user