refactor(outbox): make the trigger fan-out connection-scoped
`OutboxEventEmitter` resolved matching triggers and inserted outbox rows
through `Arc<dyn TriggerRepo>` / `Arc<dyn OutboxRepo>`, i.e. against the
pool — each query on whatever connection it happened to get. That makes a
transactional outbox impossible: the data write and the outbox rows can
never share a transaction, so a crash (or an outbox error) between them
silently loses the trigger event.
Take the emitter down to a connection instead of a pool:
* `outbox_repo::insert_on(exec, row)` and `trigger_repo::list_matching_on`
/ `list_matching_shared_on(exec, ...)` are generic over `PgExecutor`, so
the same SQL serves a pooled connection or a `&mut *tx`. The repo trait
methods delegate to them — the SQL keeps exactly one home.
* `emit_on` / `emit_shared_on` take a `&mut PgConnection` and run the whole
fan-out on it. The `ServiceEventEmitter` impl acquires one pooled
connection and calls them, so behaviour is unchanged today; a caller
holding a transaction can now pass `&mut *tx` and have the outbox rows
commit with the write.
* `OutboxEventEmitter::new` takes the `PgPool` directly (it was only ever
constructed once, in the host wiring).
Also collapses the three copy-pasted per-app match queries (kv/docs/files
differ only in the `kind` discriminator and detail table) and the three
`emit_*` bodies into one `plan()` + one match fn, so the suppression
anti-join, the chain walk, and the empty-ops-means-any-op semantic each
exist once rather than three times.
No behaviour change — pure refactor. It is the seam the transactional
write lands on next.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -605,39 +605,95 @@ impl PostgresTriggerRepo {
|
||||
pub fn new(pool: PgPool) -> Self {
|
||||
Self { pool }
|
||||
}
|
||||
}
|
||||
|
||||
/// §11.6 shared-collection match: enabled `shared = true` triggers on the
|
||||
/// OWNING group, glob + op filtered in Rust (like the per-app path). `kind`
|
||||
/// and `detail_table` are hard-coded literals from the three call sites (no
|
||||
/// injection). No chain / no suppression anti-join — a shared trigger is
|
||||
/// declared on the group that owns the collection and fires under the writer.
|
||||
async fn shared_match_rows(
|
||||
&self,
|
||||
kind: &str,
|
||||
detail_table: &str,
|
||||
owning_group: GroupId,
|
||||
collection: &str,
|
||||
op_str: &str,
|
||||
) -> Result<Vec<KvMatchRow>, TriggerRepoError> {
|
||||
let rows: Vec<KvMatchRow> = sqlx::query_as(&format!(
|
||||
"SELECT t.id, t.script_id, t.dispatch_mode, \
|
||||
t.retry_max_attempts, t.retry_backoff, t.retry_base_ms, \
|
||||
t.registered_by_principal, \
|
||||
d.collection_glob, d.ops \
|
||||
FROM triggers t \
|
||||
JOIN {detail_table} d ON d.trigger_id = t.id \
|
||||
WHERE t.kind = '{kind}' AND t.enabled = TRUE \
|
||||
AND t.shared = TRUE AND t.group_id = $1"
|
||||
))
|
||||
.bind(owning_group.into_inner())
|
||||
.fetch_all(&self.pool)
|
||||
.await?;
|
||||
Ok(rows
|
||||
.into_iter()
|
||||
.filter(|r| collection_matches(&r.collection_glob, collection))
|
||||
.filter(|r| r.ops.is_empty() || r.ops.iter().any(|o| o == op_str))
|
||||
.collect())
|
||||
}
|
||||
// ----------------------------------------------------------------------------
|
||||
// Connection-scoped event matching
|
||||
//
|
||||
// The three collection-event kinds (kv / docs / files) match identically apart
|
||||
// from the `triggers.kind` discriminator and their detail table, so both shapes
|
||||
// — the per-app CHAIN walk and the §11.6 SHARED-collection lookup — live here
|
||||
// once, parameterized on an executor.
|
||||
//
|
||||
// Taking an executor rather than `&self.pool` is what lets the transactional
|
||||
// outbox work: `outbox_event_emitter` runs the match on the SAME connection
|
||||
// (`&mut *tx`) as the data write it is fanning out, so the write and its outbox
|
||||
// rows commit together or not at all. The `list_matching_*` trait methods below
|
||||
// pass `&self.pool` and behave exactly as before.
|
||||
//
|
||||
// `kind` and `detail_table` are interpolated into the SQL, so they must stay
|
||||
// hard-coded literals from the call sites below — never user data.
|
||||
// ----------------------------------------------------------------------------
|
||||
|
||||
/// Glob + op filtering, in Rust rather than SQL. **Critical**: an empty `ops`
|
||||
/// array means "any op", which a SQL `$op = ANY(ops)` predicate would silently
|
||||
/// exclude.
|
||||
fn filter_match_rows(rows: Vec<KvMatchRow>, collection: &str, op_str: &str) -> Vec<KvMatchRow> {
|
||||
rows.into_iter()
|
||||
.filter(|r| collection_matches(&r.collection_glob, collection))
|
||||
.filter(|r| r.ops.is_empty() || r.ops.iter().any(|o| o == op_str))
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Per-app match: the firing app's OWN triggers plus any inherited from an
|
||||
/// ancestor group, minus the ones a suppression on its chain declines.
|
||||
pub(crate) async fn list_matching_on<'c, E>(
|
||||
exec: E,
|
||||
kind: &str,
|
||||
detail_table: &str,
|
||||
app_id: AppId,
|
||||
collection: &str,
|
||||
op_str: &str,
|
||||
) -> Result<Vec<KvMatchRow>, TriggerRepoError>
|
||||
where
|
||||
E: sqlx::PgExecutor<'c>,
|
||||
{
|
||||
let rows: Vec<KvMatchRow> = sqlx::query_as(&format!(
|
||||
"{CHAIN_LEVELS_CTE} \
|
||||
SELECT t.id, t.script_id, t.dispatch_mode, \
|
||||
t.retry_max_attempts, t.retry_backoff, t.retry_base_ms, \
|
||||
t.registered_by_principal, \
|
||||
d.collection_glob, d.ops \
|
||||
FROM triggers t \
|
||||
JOIN {detail_table} d ON d.trigger_id = t.id \
|
||||
JOIN chain c ON (t.app_id = c.app_owner OR t.group_id = c.group_owner) \
|
||||
WHERE t.kind = '{kind}' AND t.enabled = TRUE \
|
||||
AND t.shared = FALSE{TRIGGER_SUPPRESSION_ANTIJOIN}"
|
||||
))
|
||||
.bind(app_id.into_inner())
|
||||
.fetch_all(exec)
|
||||
.await?;
|
||||
Ok(filter_match_rows(rows, collection, op_str))
|
||||
}
|
||||
|
||||
/// §11.6 shared-collection match: enabled `shared = true` triggers on the
|
||||
/// OWNING group. No chain and no suppression anti-join — a shared trigger is
|
||||
/// declared on the group that owns the collection and fires under the writer.
|
||||
pub(crate) async fn list_matching_shared_on<'c, E>(
|
||||
exec: E,
|
||||
kind: &str,
|
||||
detail_table: &str,
|
||||
owning_group: GroupId,
|
||||
collection: &str,
|
||||
op_str: &str,
|
||||
) -> Result<Vec<KvMatchRow>, TriggerRepoError>
|
||||
where
|
||||
E: sqlx::PgExecutor<'c>,
|
||||
{
|
||||
let rows: Vec<KvMatchRow> = sqlx::query_as(&format!(
|
||||
"SELECT t.id, t.script_id, t.dispatch_mode, \
|
||||
t.retry_max_attempts, t.retry_backoff, t.retry_base_ms, \
|
||||
t.registered_by_principal, \
|
||||
d.collection_glob, d.ops \
|
||||
FROM triggers t \
|
||||
JOIN {detail_table} d ON d.trigger_id = t.id \
|
||||
WHERE t.kind = '{kind}' AND t.enabled = TRUE \
|
||||
AND t.shared = TRUE AND t.group_id = $1"
|
||||
))
|
||||
.bind(owning_group.into_inner())
|
||||
.fetch_all(exec)
|
||||
.await?;
|
||||
Ok(filter_match_rows(rows, collection, op_str))
|
||||
}
|
||||
|
||||
/// Insert a trigger (parent row + per-kind detail) within an existing
|
||||
@@ -1483,48 +1539,16 @@ impl TriggerRepo for PostgresTriggerRepo {
|
||||
collection: &str,
|
||||
op: KvEventOp,
|
||||
) -> Result<Vec<KvTriggerMatch>, TriggerRepoError> {
|
||||
// Fetch all enabled KV triggers for the app — glob matching
|
||||
// happens in Rust so we don't have to teach the query about
|
||||
// `*` and `prefix:*`. Sets are tiny in practice (one app's
|
||||
// worth of triggers, usually a handful).
|
||||
let rows: Vec<KvMatchRow> = sqlx::query_as(&format!(
|
||||
"{CHAIN_LEVELS_CTE} \
|
||||
SELECT t.id, t.script_id, t.dispatch_mode, \
|
||||
t.retry_max_attempts, t.retry_backoff, t.retry_base_ms, \
|
||||
t.registered_by_principal, \
|
||||
d.collection_glob, d.ops \
|
||||
FROM triggers t \
|
||||
JOIN kv_trigger_details d ON d.trigger_id = t.id \
|
||||
JOIN chain c ON (t.app_id = c.app_owner OR t.group_id = c.group_owner) \
|
||||
WHERE t.kind = 'kv' AND t.enabled = TRUE \
|
||||
AND t.shared = FALSE{TRIGGER_SUPPRESSION_ANTIJOIN}"
|
||||
))
|
||||
.bind(app_id.into_inner())
|
||||
.fetch_all(&self.pool)
|
||||
let rows = list_matching_on(
|
||||
&self.pool,
|
||||
"kv",
|
||||
"kv_trigger_details",
|
||||
app_id,
|
||||
collection,
|
||||
op.as_str(),
|
||||
)
|
||||
.await?;
|
||||
|
||||
let op_str = op.as_str();
|
||||
let mut out = Vec::new();
|
||||
for r in rows {
|
||||
if !collection_matches(&r.collection_glob, collection) {
|
||||
continue;
|
||||
}
|
||||
let any_op = r.ops.is_empty();
|
||||
if !any_op && !r.ops.iter().any(|o| o == op_str) {
|
||||
continue;
|
||||
}
|
||||
out.push(KvTriggerMatch {
|
||||
trigger_id: r.id.into(),
|
||||
script_id: r.script_id.into(),
|
||||
dispatch_mode: dispatch_from_str(&r.dispatch_mode),
|
||||
retry_max_attempts: u32::try_from(r.retry_max_attempts).unwrap_or(3),
|
||||
retry_backoff: BackoffShape::from_wire(&r.retry_backoff)
|
||||
.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(),
|
||||
});
|
||||
}
|
||||
Ok(out)
|
||||
Ok(rows.into_iter().map(KvMatchRow::into_kv).collect())
|
||||
}
|
||||
|
||||
async fn list_matching_docs(
|
||||
@@ -1533,49 +1557,16 @@ impl TriggerRepo for PostgresTriggerRepo {
|
||||
collection: &str,
|
||||
op: DocsEventOp,
|
||||
) -> Result<Vec<DocsTriggerMatch>, TriggerRepoError> {
|
||||
// Mirrors list_matching_kv: pull every enabled docs trigger,
|
||||
// filter glob + ops in Rust. **Critical**: do NOT push the
|
||||
// ops check into SQL (`WHERE $op = ANY(ops)`) — that would
|
||||
// exclude rows with `ops = '{}'` from the results, breaking
|
||||
// the empty-array-means-any-op semantic.
|
||||
let rows: Vec<KvMatchRow> = sqlx::query_as(&format!(
|
||||
"{CHAIN_LEVELS_CTE} \
|
||||
SELECT t.id, t.script_id, t.dispatch_mode, \
|
||||
t.retry_max_attempts, t.retry_backoff, t.retry_base_ms, \
|
||||
t.registered_by_principal, \
|
||||
d.collection_glob, d.ops \
|
||||
FROM triggers t \
|
||||
JOIN docs_trigger_details d ON d.trigger_id = t.id \
|
||||
JOIN chain c ON (t.app_id = c.app_owner OR t.group_id = c.group_owner) \
|
||||
WHERE t.kind = 'docs' AND t.enabled = TRUE \
|
||||
AND t.shared = FALSE{TRIGGER_SUPPRESSION_ANTIJOIN}"
|
||||
))
|
||||
.bind(app_id.into_inner())
|
||||
.fetch_all(&self.pool)
|
||||
let rows = list_matching_on(
|
||||
&self.pool,
|
||||
"docs",
|
||||
"docs_trigger_details",
|
||||
app_id,
|
||||
collection,
|
||||
op.as_str(),
|
||||
)
|
||||
.await?;
|
||||
|
||||
let op_str = op.as_str();
|
||||
let mut out = Vec::new();
|
||||
for r in rows {
|
||||
if !collection_matches(&r.collection_glob, collection) {
|
||||
continue;
|
||||
}
|
||||
let any_op = r.ops.is_empty();
|
||||
if !any_op && !r.ops.iter().any(|o| o == op_str) {
|
||||
continue;
|
||||
}
|
||||
out.push(DocsTriggerMatch {
|
||||
trigger_id: r.id.into(),
|
||||
script_id: r.script_id.into(),
|
||||
dispatch_mode: dispatch_from_str(&r.dispatch_mode),
|
||||
retry_max_attempts: u32::try_from(r.retry_max_attempts).unwrap_or(3),
|
||||
retry_backoff: BackoffShape::from_wire(&r.retry_backoff)
|
||||
.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(),
|
||||
});
|
||||
}
|
||||
Ok(out)
|
||||
Ok(rows.into_iter().map(KvMatchRow::into_docs).collect())
|
||||
}
|
||||
|
||||
async fn list_matching_files(
|
||||
@@ -1584,46 +1575,16 @@ impl TriggerRepo for PostgresTriggerRepo {
|
||||
collection: &str,
|
||||
op: FilesEventOp,
|
||||
) -> Result<Vec<FilesTriggerMatch>, TriggerRepoError> {
|
||||
// Mirrors list_matching_kv: pull every enabled files trigger,
|
||||
// filter glob + ops in Rust (empty ops array means "any op").
|
||||
let rows: Vec<KvMatchRow> = sqlx::query_as(&format!(
|
||||
"{CHAIN_LEVELS_CTE} \
|
||||
SELECT t.id, t.script_id, t.dispatch_mode, \
|
||||
t.retry_max_attempts, t.retry_backoff, t.retry_base_ms, \
|
||||
t.registered_by_principal, \
|
||||
d.collection_glob, d.ops \
|
||||
FROM triggers t \
|
||||
JOIN files_trigger_details d ON d.trigger_id = t.id \
|
||||
JOIN chain c ON (t.app_id = c.app_owner OR t.group_id = c.group_owner) \
|
||||
WHERE t.kind = 'files' AND t.enabled = TRUE \
|
||||
AND t.shared = FALSE{TRIGGER_SUPPRESSION_ANTIJOIN}"
|
||||
))
|
||||
.bind(app_id.into_inner())
|
||||
.fetch_all(&self.pool)
|
||||
let rows = list_matching_on(
|
||||
&self.pool,
|
||||
"files",
|
||||
"files_trigger_details",
|
||||
app_id,
|
||||
collection,
|
||||
op.as_str(),
|
||||
)
|
||||
.await?;
|
||||
|
||||
let op_str = op.as_str();
|
||||
let mut out = Vec::new();
|
||||
for r in rows {
|
||||
if !collection_matches(&r.collection_glob, collection) {
|
||||
continue;
|
||||
}
|
||||
let any_op = r.ops.is_empty();
|
||||
if !any_op && !r.ops.iter().any(|o| o == op_str) {
|
||||
continue;
|
||||
}
|
||||
out.push(FilesTriggerMatch {
|
||||
trigger_id: r.id.into(),
|
||||
script_id: r.script_id.into(),
|
||||
dispatch_mode: dispatch_from_str(&r.dispatch_mode),
|
||||
retry_max_attempts: u32::try_from(r.retry_max_attempts).unwrap_or(3),
|
||||
retry_backoff: BackoffShape::from_wire(&r.retry_backoff)
|
||||
.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(),
|
||||
});
|
||||
}
|
||||
Ok(out)
|
||||
Ok(rows.into_iter().map(KvMatchRow::into_files).collect())
|
||||
}
|
||||
|
||||
async fn list_matching_shared_kv(
|
||||
@@ -1632,15 +1593,15 @@ impl TriggerRepo for PostgresTriggerRepo {
|
||||
collection: &str,
|
||||
op: KvEventOp,
|
||||
) -> Result<Vec<KvTriggerMatch>, TriggerRepoError> {
|
||||
let rows = self
|
||||
.shared_match_rows(
|
||||
"kv",
|
||||
"kv_trigger_details",
|
||||
owning_group,
|
||||
collection,
|
||||
op.as_str(),
|
||||
)
|
||||
.await?;
|
||||
let rows = list_matching_shared_on(
|
||||
&self.pool,
|
||||
"kv",
|
||||
"kv_trigger_details",
|
||||
owning_group,
|
||||
collection,
|
||||
op.as_str(),
|
||||
)
|
||||
.await?;
|
||||
Ok(rows.into_iter().map(KvMatchRow::into_kv).collect())
|
||||
}
|
||||
|
||||
@@ -1650,15 +1611,15 @@ impl TriggerRepo for PostgresTriggerRepo {
|
||||
collection: &str,
|
||||
op: DocsEventOp,
|
||||
) -> Result<Vec<DocsTriggerMatch>, TriggerRepoError> {
|
||||
let rows = self
|
||||
.shared_match_rows(
|
||||
"docs",
|
||||
"docs_trigger_details",
|
||||
owning_group,
|
||||
collection,
|
||||
op.as_str(),
|
||||
)
|
||||
.await?;
|
||||
let rows = list_matching_shared_on(
|
||||
&self.pool,
|
||||
"docs",
|
||||
"docs_trigger_details",
|
||||
owning_group,
|
||||
collection,
|
||||
op.as_str(),
|
||||
)
|
||||
.await?;
|
||||
Ok(rows.into_iter().map(KvMatchRow::into_docs).collect())
|
||||
}
|
||||
|
||||
@@ -1668,15 +1629,15 @@ impl TriggerRepo for PostgresTriggerRepo {
|
||||
collection: &str,
|
||||
op: FilesEventOp,
|
||||
) -> Result<Vec<FilesTriggerMatch>, TriggerRepoError> {
|
||||
let rows = self
|
||||
.shared_match_rows(
|
||||
"files",
|
||||
"files_trigger_details",
|
||||
owning_group,
|
||||
collection,
|
||||
op.as_str(),
|
||||
)
|
||||
.await?;
|
||||
let rows = list_matching_shared_on(
|
||||
&self.pool,
|
||||
"files",
|
||||
"files_trigger_details",
|
||||
owning_group,
|
||||
collection,
|
||||
op.as_str(),
|
||||
)
|
||||
.await?;
|
||||
Ok(rows.into_iter().map(KvMatchRow::into_files).collect())
|
||||
}
|
||||
|
||||
@@ -2169,9 +2130,9 @@ struct DlDetailRow {
|
||||
}
|
||||
|
||||
#[derive(sqlx::FromRow)]
|
||||
struct KvMatchRow {
|
||||
id: Uuid,
|
||||
script_id: Uuid,
|
||||
pub(crate) struct KvMatchRow {
|
||||
pub(crate) id: Uuid,
|
||||
pub(crate) script_id: Uuid,
|
||||
dispatch_mode: String,
|
||||
retry_max_attempts: i32,
|
||||
retry_backoff: String,
|
||||
|
||||
Reference in New Issue
Block a user