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:
MechaCat02
2026-07-15 22:20:24 +02:00
parent 6b2dcd41a6
commit eae2ee08f1
7 changed files with 496 additions and 16 deletions

View File

@@ -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"),
}
}