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"),
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user