uni_plugin/traits/trigger.rs
1//! Fine-grained triggers — APOC `apoc.trigger.*` analogue.
2
3use std::sync::Arc;
4
5use datafusion::arrow::record_batch::RecordBatch;
6use smol_str::SmolStr;
7
8use crate::errors::FnError;
9use crate::traits::procedure::ProcedureHost;
10
11/// A fine-grained mutation trigger.
12pub trait TriggerPlugin: Send + Sync {
13 /// Subscription describing which events this trigger receives.
14 fn subscription(&self) -> &TriggerSubscription;
15
16 /// Fire the trigger with a batch of matching mutation events.
17 ///
18 /// # Threading policy
19 ///
20 /// `fire` is synchronous; the host wraps it differently depending
21 /// on the subscription's [`FireMode`]:
22 ///
23 /// - [`FireMode::Synchronous`] — invoked inline on the transaction
24 /// commit path, on a `tokio::task::spawn_blocking` worker
25 /// thread. Returning [`TriggerOutcome::Reject`] aborts the
26 /// transaction; long-running work blocks the committer, so keep
27 /// the body tight.
28 /// - [`FireMode::Async`] — fires off a separate `spawn_blocking`
29 /// task after the transaction commits; cannot reject (the
30 /// transaction has already landed). Failures are logged but do
31 /// not roll back.
32 /// - [`FireMode::EventualConsistency`] — batched via the
33 /// `BackgroundJobProvider` machinery; the same blocking-worker
34 /// contract from [`crate::traits::background::BackgroundJobProvider::execute`] applies.
35 ///
36 /// In every mode the body must not call `block_on` against the
37 /// host runtime; panics are caught at the dispatcher boundary.
38 ///
39 /// See `docs/PLUGIN_THREADING.md` for the long-form rationale.
40 ///
41 /// # Errors
42 ///
43 /// Returns [`FnError`] if the fire cannot complete. For `Synchronous`
44 /// triggers this aborts the surrounding transaction.
45 fn fire(
46 &self,
47 ctx: TriggerContext<'_>,
48 events: &MutationBatch,
49 ) -> Result<TriggerOutcome, FnError>;
50
51 /// Re-fire after a [`TriggerOutcome::Defer`] previously returned.
52 ///
53 /// The host's deferral queue invokes this with the original
54 /// `payload` once the `delay` has elapsed. The default
55 /// implementation delegates back to [`Self::fire`] with the
56 /// original [`MutationBatch`] — existing trigger plugins keep
57 /// working without changes. Plugins that need access to the
58 /// `payload` (e.g., to resume a long-running aggregation) override
59 /// this method.
60 ///
61 /// Returning [`TriggerOutcome::Defer`] from `on_deferred` re-queues
62 /// the item with `attempt + 1`, capped at the host's
63 /// `DEFER_MAX_ATTEMPTS`.
64 ///
65 /// # Errors
66 ///
67 /// Returns [`FnError`] when the deferred fire cannot complete.
68 /// The error is logged at warn and the item is dropped.
69 fn on_deferred(
70 &self,
71 ctx: TriggerContext<'_>,
72 events: &MutationBatch,
73 _payload: &str,
74 ) -> Result<TriggerOutcome, FnError> {
75 self.fire(ctx, events)
76 }
77}
78
79/// Selectors describing the events this trigger subscribes to.
80#[derive(Clone, Debug)]
81pub struct TriggerSubscription {
82 /// Phase in the mutation lifecycle.
83 pub phase: TriggerPhase,
84 /// Event-kind bitmask (`NodeCreate | NodeUpdate | EdgeDelete | ...`).
85 pub events: TriggerEventMask,
86 /// Optional label allow-list; `None` means all labels.
87 pub labels: Option<Vec<SmolStr>>,
88 /// Optional edge-type allow-list.
89 pub edge_types: Option<Vec<SmolStr>>,
90 /// Optional property allow-list — for `*Update` events, restrict to
91 /// updates touching these properties.
92 pub properties: Option<Vec<SmolStr>>,
93 /// Cypher boolean expression evaluated per event (parsed by host).
94 pub predicate_source: Option<String>,
95 /// Firing mode (Sync / Async / Eventual).
96 pub fire_mode: FireMode,
97 /// Markdown docs.
98 pub docs: String,
99}
100
101/// Lifecycle phase a trigger subscribes to.
102#[derive(Clone, Copy, Debug, PartialEq, Eq)]
103#[non_exhaustive]
104pub enum TriggerPhase {
105 /// Before the mutation is applied — may reject.
106 BeforeMutation,
107 /// After the mutation is applied, in the same transaction.
108 AfterMutation,
109 /// Before transaction commit — may reject.
110 BeforeCommit,
111 /// After transaction commit; cannot reject.
112 AfterCommit,
113}
114
115/// Bitmask of event kinds.
116#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
117pub struct TriggerEventMask(pub u32);
118
119impl TriggerEventMask {
120 /// Node creation.
121 pub const NODE_CREATE: Self = Self(1 << 0);
122 /// Node update.
123 pub const NODE_UPDATE: Self = Self(1 << 1);
124 /// Node deletion.
125 pub const NODE_DELETE: Self = Self(1 << 2);
126 /// Edge creation.
127 pub const EDGE_CREATE: Self = Self(1 << 3);
128 /// Edge update.
129 pub const EDGE_UPDATE: Self = Self(1 << 4);
130 /// Edge deletion.
131 pub const EDGE_DELETE: Self = Self(1 << 5);
132 /// Property change (covered by Node/Edge Update — independent bit for
133 /// finer-grained matching).
134 pub const PROPERTY_CHANGE: Self = Self(1 << 6);
135 /// Label added.
136 pub const LABEL_ADDED: Self = Self(1 << 7);
137 /// Label removed.
138 pub const LABEL_REMOVED: Self = Self(1 << 8);
139
140 /// Combine two masks.
141 #[must_use]
142 pub const fn union(self, other: Self) -> Self {
143 Self(self.0 | other.0)
144 }
145
146 /// Check whether this mask is a superset of `other`.
147 #[must_use]
148 pub const fn contains(self, other: Self) -> bool {
149 (self.0 & other.0) == other.0
150 }
151}
152
153/// Firing mode.
154#[derive(Clone, Copy, Debug, PartialEq, Eq)]
155#[non_exhaustive]
156pub enum FireMode {
157 /// Synchronous — blocks the mutation; may reject.
158 Synchronous,
159 /// Fires after commit; cannot reject.
160 Async,
161 /// Eventually consistent — batched via `BackgroundJobProvider`.
162 EventualConsistency,
163}
164
165/// Outcome returned by a trigger.
166#[derive(Debug)]
167#[non_exhaustive]
168pub enum TriggerOutcome {
169 /// Continue normally.
170 Continue,
171 /// Reject the surrounding mutation / transaction (valid only in
172 /// `Before*` phases).
173 Reject {
174 /// Human-readable rejection reason.
175 reason: String,
176 },
177 /// Defer this trigger's firing (e.g., for batched aggregation).
178 Defer {
179 /// Deferral metadata understood by the trigger implementation.
180 until: TriggerDeferral,
181 },
182}
183
184/// Deferral marker returned by [`TriggerOutcome::Defer`].
185///
186/// Carries an implementation-defined `payload` plus an optional
187/// `delay` (FU-5). When `delay` is `None` the deferred item re-fires
188/// on the next scheduler tick (legacy "any moment now" semantics);
189/// when `Some(d)` the host's deferral queue waits at least `d` before
190/// re-invoking the trigger.
191#[derive(Clone, Debug)]
192#[non_exhaustive]
193pub struct TriggerDeferral {
194 /// Implementation-defined payload — opaque to the host. Persisted
195 /// across `Uni` restarts when the host's durable defer queue is
196 /// enabled.
197 pub payload: String,
198 /// Wait at least this duration before re-firing. `None` means "as
199 /// soon as the next tick fires" (~50–100 ms).
200 pub delay: Option<std::time::Duration>,
201}
202
203impl TriggerDeferral {
204 /// Construct a deferral with no delay.
205 ///
206 /// Use this when the trigger is simply asking "re-queue me for
207 /// the next tick" — e.g., when an external prerequisite resource
208 /// might become available at any moment.
209 #[must_use]
210 pub fn from_payload(payload: impl Into<String>) -> Self {
211 Self {
212 payload: payload.into(),
213 delay: None,
214 }
215 }
216
217 /// Construct a deferral with an explicit `delay`.
218 ///
219 /// The host's deferral queue waits at least `delay` before
220 /// re-invoking [`TriggerPlugin::on_deferred`] (or, when the host
221 /// has not adopted the `on_deferred` callback, [`TriggerPlugin::fire`]
222 /// with the original [`MutationBatch`]).
223 #[must_use]
224 pub fn after(payload: impl Into<String>, delay: std::time::Duration) -> Self {
225 Self {
226 payload: payload.into(),
227 delay: Some(delay),
228 }
229 }
230}
231
232/// Per-fire context.
233///
234/// # ABI note (3.0 breaking change)
235///
236/// Carries an **owned** optional [`ProcedureHost`] handle so a declared
237/// (synthesized) trigger can reach the host's write-enabled inner-query
238/// primitive from inside `fire`. The handle is owned (not borrowed)
239/// because the after-commit async dispatch path moves the context into a
240/// `'static` spawned task and rebuilds it there — a borrow could not
241/// outlive the commit stack frame. Native trigger plugins that never
242/// touch `host()` are unaffected (the field defaults to `None`).
243#[non_exhaustive]
244pub struct TriggerContext<'a> {
245 /// Session identifier.
246 pub session_id: &'a str,
247 /// Transaction identifier.
248 pub tx_id: u64,
249 /// Owned host handle, threaded through the commit path so a
250 /// declared trigger's Cypher action body can run against the same
251 /// storage / writer the outer commit saw. `None` for native
252 /// trigger plugins and for contexts built without a host.
253 host: Option<Arc<dyn ProcedureHost>>,
254}
255
256impl std::fmt::Debug for TriggerContext<'_> {
257 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
258 f.debug_struct("TriggerContext")
259 .field("session_id", &self.session_id)
260 .field("tx_id", &self.tx_id)
261 .field("host", &self.host.as_ref().map(|_| "<ProcedureHost>"))
262 .finish()
263 }
264}
265
266impl<'a> TriggerContext<'a> {
267 /// Construct a fresh context with no host handle. The struct is
268 /// `#[non_exhaustive]` so external callers can't use struct-literal
269 /// syntax; this constructor is the supported path. Future fields
270 /// ship via `with_*` builder methods to preserve API compatibility.
271 #[must_use]
272 pub fn new(session_id: &'a str, tx_id: u64) -> Self {
273 Self {
274 session_id,
275 tx_id,
276 host: None,
277 }
278 }
279
280 /// Attach an owned host handle to this context.
281 ///
282 /// The commit-path dispatcher threads a write-enabled host through so
283 /// a declared trigger's `fire` can downcast it (via
284 /// [`ProcedureHost::as_any`]) and run its stored Cypher action body.
285 #[must_use]
286 pub fn with_host(mut self, host: Arc<dyn ProcedureHost>) -> Self {
287 self.host = Some(host);
288 self
289 }
290
291 /// Borrow the attached host handle, when one was threaded in.
292 #[must_use]
293 pub fn host(&self) -> Option<&Arc<dyn ProcedureHost>> {
294 self.host.as_ref()
295 }
296}
297
298/// Batch of mutation events delivered to a trigger.
299///
300/// The batch's `RecordBatch` schema is host-defined and stable:
301/// `event_kind | vid_or_eid | label | property | old_value | new_value | …`.
302#[derive(Clone, Debug)]
303pub struct MutationBatch {
304 /// The events as a typed columnar batch.
305 pub events: Arc<RecordBatch>,
306}