areev_trigger/eval.rs
1//! The evaluation cycle: find due, claim, invoke, ingest, start, release.
2//!
3//! One invocation performs the whole cycle and exits. Nothing stays resident.
4//! Losing a claim race is a no-op exit, not an error — that is the steady state
5//! when several nodes share a heartbeat.
6//!
7//! ## Why correctness does not rest on the lease
8//!
9//! A lease deduplicates *"who is working right now"*. But the thing that
10//! actually needs deduplicating — a firing of trigger T for item I — has a
11//! natural identity, so the run id is derived from it. `areev run start`
12//! already refuses a duplicate run id, so two evaluators racing produce one run
13//! and one recorded skip, with no lease consulted at all: no duration to guess,
14//! no fencing token to compare, no clock-skew window.
15//!
16//! The lease then does one narrower job: stopping two nodes from both making
17//! the same expensive, side-effecting connector call. Losing it can cost a
18//! wasted API call; it can never cause a missed or duplicated firing. That is
19//! the risk profile a lease should carry.
20
21use std::sync::Arc;
22
23use areev_cal::AreevFacade;
24use areev_core::types::{Event, Grain, GrainType, Observation, Trigger, TriggerKind};
25use areev_run::HostToolExecutor;
26use areev_store::TriggerState;
27use serde::Serialize;
28use sha2::{Digest, Sha256};
29
30use crate::clock::Clock;
31use crate::connector::{dedup_value, PollItem, PollRequest, PollResponse};
32use crate::error::{Result, TriggerError};
33use crate::schedule;
34
35/// Namespace for trigger firing records. Alongside the runtime's own
36/// `agent:harness`, so operational journalling stays out of user namespaces.
37pub const TRIGGER_NS: &str = "agent:triggers";
38
39/// How many triggers one invocation will look at. A backstop, not a policy.
40const MAX_TRIGGERS_SCANNED: usize = 4096;
41
42/// Knobs for one evaluation pass.
43#[derive(Debug, Clone)]
44pub struct EvalOptions {
45 /// Evaluate only this trigger (its content address).
46 pub only: Option<String>,
47 /// How long a claim is held before another evaluator may take over.
48 /// Deliberately generous: a lease shorter than the worst-case connector
49 /// runtime causes takeovers mid-poll, which costs duplicate API calls.
50 pub lease: std::time::Duration,
51 /// Cap on items requested per firing.
52 pub max_items: usize,
53 /// Report what would happen and touch nothing. The safe first command on a
54 /// new deployment.
55 pub dry_run: bool,
56 /// This evaluator's identity, recorded in the claim.
57 pub node: String,
58}
59
60impl Default for EvalOptions {
61 fn default() -> Self {
62 EvalOptions {
63 only: None,
64 lease: std::time::Duration::from_secs(300),
65 max_items: 100,
66 dry_run: false,
67 node: default_node_id(),
68 }
69 }
70}
71
72/// First `n` characters, safely — `&s[..n]` panics on a short string or a
73/// multi-byte boundary, and a message-formatting helper must not be what takes
74/// the process down. Declarations can arrive by bundle import from an
75/// implementation we did not write.
76fn short(s: &str, n: usize) -> &str {
77 match s.char_indices().nth(n) {
78 Some((idx, _)) => &s[..idx],
79 None => s,
80 }
81}
82
83/// A stable-ish identity for this evaluator. Host name plus pid: enough to tell
84/// two nodes apart in a claim, and to recognise our own stale claim after a
85/// restart.
86fn default_node_id() -> String {
87 let host = std::env::var("HOSTNAME")
88 .or_else(|_| std::env::var("COMPUTERNAME"))
89 .unwrap_or_else(|_| "node".into());
90 format!("{host}/{}", std::process::id())
91}
92
93/// What one evaluation pass did. Shaped like `loop run`'s report for symmetry.
94#[derive(Debug, Clone, Default, Serialize)]
95pub struct EvalReport {
96 pub claimed: usize,
97 pub skipped_not_due: usize,
98 pub skipped_locked: usize,
99 pub skipped_paused: usize,
100 pub items: usize,
101 pub duplicates: usize,
102 pub runs_started: usize,
103 /// Items recorded without starting a workflow, because no runtime is wired
104 /// in. Counted apart from `runs_started` so the report never claims a run
105 /// happened when none did.
106 pub ingested: usize,
107 /// Items the connector returned that carry no usable identity, so no run
108 /// could be minted for them. Reported rather than silently dropped: an item
109 /// nobody can name is a connector bug, and it should be visible as one.
110 pub unidentifiable: usize,
111 /// Declarations that cannot fire as written — a cron that does not parse, a
112 /// timezone this build refuses, a composite gate naming a member the
113 /// declaration does not carry.
114 ///
115 /// Counted APART from `skipped_not_due` on purpose. Folded in there, an
116 /// unusable trigger is indistinguishable from a healthy one waiting its
117 /// turn, which is the failure mode with no symptom: the work simply never
118 /// happens and every report looks green. The reason lands in `errors`.
119 pub unusable: usize,
120 /// Not fatal to the pass: one broken trigger must not stop the others.
121 pub errors: Vec<String>,
122 /// A connector reported a backlog, so this trigger is due again at once
123 /// rather than after its interval.
124 pub draining: Vec<String>,
125}
126
127/// One trigger considered, for `--dry-run` and `status`.
128#[derive(Debug, Clone, Serialize)]
129pub struct TriggerStatus {
130 pub trigger: String,
131 pub kind: String,
132 pub workflow: String,
133 pub enabled: bool,
134 pub paused: bool,
135 pub due: bool,
136 pub leased_by: Option<String>,
137 pub next_due_at: Option<i64>,
138 pub last_fired_at: Option<i64>,
139 pub consecutive_failures: u32,
140 pub last_error: Option<String>,
141 /// Will never fire again — a one-shot past its instant.
142 pub exhausted: bool,
143 /// Never fired and no failure recorded — the state an unnoticed
144 /// misconfiguration sits in, so it is reported rather than inferred.
145 pub never_fired: bool,
146 /// Why this declaration can never fire, if it cannot.
147 ///
148 /// A trigger can become unusable without ever being written through a
149 /// validating path — it can arrive by bundle import from an implementation
150 /// that validated differently, or predate the check. So the evaluator
151 /// reports it rather than assuming the write path caught everything.
152 #[serde(skip_serializing_if = "Option::is_none")]
153 pub unusable: Option<String>,
154}
155
156/// Starts the workflow a trigger is bound to.
157///
158/// A seam rather than a direct `areev_run::Runner` call, for the same reason
159/// the CAL executor takes a governance host: the runtime sits above this crate
160/// in the dependency order for hosts that want it, and a test needs to observe
161/// what *would* be started without spending a real run. It is also where the
162/// duplicate rule lives, which is the whole idempotency story.
163pub trait RunStarter: Send + Sync {
164 fn start(&self, workflow: &str, run_id: &str, input: serde_json::Value) -> StartResult;
165}
166
167/// What starting a run did.
168#[derive(Debug, Clone, PartialEq)]
169pub enum StartResult {
170 Started,
171 /// A run with this id already exists. **Not an error** — it is the
172 /// idempotency guarantee working: this item was already handled, by an
173 /// earlier firing or by another node that won the race.
174 Duplicate,
175 Failed(String),
176}
177
178/// Evaluates triggers against one memory.
179pub struct Evaluator {
180 pub facade: Arc<AreevFacade>,
181 pub clock: Arc<dyn Clock>,
182 /// Executes connector commands. `None` means a due polling trigger fails
183 /// loudly with `TRG-E003` rather than quietly doing nothing — a poll that
184 /// silently returns no items is indistinguishable from a healthy source.
185 pub connector: Option<Arc<dyn HostToolExecutor>>,
186 /// Starts the bound workflow. `None` ingests the item and records the
187 /// firing without starting anything — what `--no-start` is for, and what
188 /// the ingest-only tests use.
189 pub starter: Option<Arc<dyn RunStarter>>,
190 /// Credentials the broker may attach, by name. Values live here and never
191 /// in a grain or a connector's environment: a declaration names a
192 /// credential, it never carries one.
193 pub credentials: std::collections::BTreeMap<String, areev_run::Credential>,
194 pub ns: String,
195 pub principal: String,
196}
197
198impl Evaluator {
199 /// An evaluator that can inspect but not act: no connector, no runtime, no
200 /// credentials. What `list`, `show` and `status` want, and the shape that
201 /// makes "reading cannot fire anything" true by construction rather than by
202 /// remembering to pass `None` four times.
203 pub fn read_only(facade: Arc<AreevFacade>, clock: Arc<dyn Clock>, ns: &str) -> Evaluator {
204 Evaluator {
205 facade,
206 clock,
207 connector: None,
208 starter: None,
209 credentials: Default::default(),
210 ns: ns.to_string(),
211 principal: "user:local".into(),
212 }
213 }
214
215 /// Every trigger declaration in the memory, newest first.
216 pub fn declarations(&self) -> Result<Vec<(String, Trigger)>> {
217 let ns = self.ns.clone();
218 let grains = self
219 .facade
220 .with_store(|m| {
221 m.recent_live_scoped(&[ns], Some(GrainType::Trigger), MAX_TRIGGERS_SCANNED)
222 })
223 .map_err(|e| TriggerError::Storage { detail: e.to_string() })?;
224
225 let mut out = Vec::new();
226 for g in grains {
227 let hash = g.hash.to_hex();
228 match g.to_trigger() {
229 Ok(t) => out.push((hash, t)),
230 // A declaration this build cannot read is reported, never
231 // skipped silently: silence is the symptom of every trigger
232 // failure, so it must never also be the symptom of a bug.
233 Err(e) => {
234 return Err(TriggerError::Malformed {
235 what: format!("declaration {hash} is unreadable: {e}"),
236 })
237 }
238 }
239 }
240 Ok(out)
241 }
242
243 /// What every trigger's state looks like right now.
244 pub fn status(&self) -> Result<Vec<TriggerStatus>> {
245 let now = self.clock.now_ms();
246 let mut out = Vec::new();
247 for (hash, t) in self.declarations()? {
248 let st = self
249 .facade
250 .with_store(|m| m.trigger_state(&hash))
251 .map_err(|e| TriggerError::Storage { detail: e.to_string() })?
252 .map(|(s, _)| s)
253 .unwrap_or_default();
254 let unusable = schedule::validate(&t).err().map(|e| e.to_string());
255 out.push(TriggerStatus {
256 trigger: hash,
257 kind: t.kind.as_str().to_string(),
258 workflow: t.workflow.clone(),
259 enabled: t.enabled,
260 paused: st.paused,
261 // An unusable declaration is never due. Saying otherwise would
262 // promise a firing that cannot happen.
263 due: unusable.is_none()
264 && t.enabled
265 && !st.leased(now)
266 && schedule::is_due(&t, &st, now).unwrap_or(false),
267 leased_by: st.leased(now).then(|| st.claimed_by.clone()).flatten(),
268 next_due_at: st.next_due_at,
269 last_fired_at: st.last_fired_at,
270 consecutive_failures: st.consecutive_failures,
271 last_error: st.last_error.clone(),
272 exhausted: st.exhausted,
273 never_fired: st.last_fired_at.is_none() && st.consecutive_failures == 0,
274 unusable,
275 });
276 }
277 Ok(out)
278 }
279
280 /// Ingest a payload delivered by the host for a `webhook` or `manual`
281 /// trigger.
282 ///
283 /// Areev never opens a port. The host already terminates TLS and
284 /// authenticates the sender — it is far better at both than a memory engine
285 /// would be — and hands the payload to a one-shot invocation. That keeps the
286 /// useful half of webhooks without a listening daemon.
287 ///
288 /// Idempotent on the same terms as a poll: the payload's dedup value mints
289 /// the run id, so a webhook delivered twice (which every provider does)
290 /// produces one run and one recorded skip.
291 pub fn deliver(&self, trigger_hash: &str, payload: serde_json::Value) -> Result<EvalReport> {
292 let now = self.clock.now_ms();
293 let (hash, trigger) = self
294 .declarations()?
295 .into_iter()
296 .find(|(h, _)| h.starts_with(trigger_hash))
297 .ok_or_else(|| TriggerError::Malformed {
298 what: format!("no trigger matching '{trigger_hash}'"),
299 })?;
300
301 if !matches!(trigger.kind, TriggerKind::Webhook | TriggerKind::Manual) {
302 return Err(TriggerError::Malformed {
303 what: format!(
304 "trigger {} is a {} trigger — only webhook and manual triggers accept a \
305 delivery; a clocked or polling trigger fires from `trigger run`",
306 short(&hash, 12),
307 trigger.kind.as_str()
308 ),
309 });
310 }
311 if !trigger.enabled {
312 return Err(TriggerError::Malformed {
313 what: format!("trigger {} is disabled", short(&hash, 12)),
314 });
315 }
316 let (state, _) = self
317 .facade
318 .with_store(|m| m.trigger_state(&hash))
319 .map_err(|e| TriggerError::Storage { detail: e.to_string() })?
320 .unwrap_or_default();
321 if state.paused {
322 return Err(TriggerError::Malformed {
323 what: format!("trigger {} is paused", short(&hash, 12)),
324 });
325 }
326
327 let mut report = EvalReport::default();
328 let item = PollItem { id: String::new(), payload };
329 report.items = 1;
330 let Some(value) = dedup_value(&item, &trigger.dedup_key) else {
331 report.unidentifiable = 1;
332 // Journal before returning. A delivery naming nothing is precisely
333 // the case the record exists for: the sender's payload shape
334 // changed under us, and stdout of whatever invoked this is not an
335 // audit record -- the replicating Observation is.
336 self.journal(
337 &hash,
338 &trigger,
339 &FireOutcome { items: 1, unidentifiable: 1, ..Default::default() },
340 Some("delivered"),
341 now,
342 )?;
343 return Ok(report);
344 };
345 let run_id = run_id_for(&hash, trigger.connector.as_deref(), &value);
346 match self.start_run(&trigger, &run_id, &item, &hash) {
347 StartOutcome::Started => report.runs_started = 1,
348 StartOutcome::Ingested => report.ingested = 1,
349 StartOutcome::Duplicate => report.duplicates = 1,
350 StartOutcome::Failed(why) => report.errors.push(why),
351 }
352
353 let outcome = FireOutcome {
354 items: 1,
355 runs_started: report.runs_started,
356 duplicates: report.duplicates,
357 ..Default::default()
358 };
359 self.journal(&hash, &trigger, &outcome, Some("delivered"), now)?;
360 Ok(report)
361 }
362
363 /// Run one evaluation pass.
364 pub fn run(&self, opts: &EvalOptions) -> Result<EvalReport> {
365 let mut report = EvalReport::default();
366 let now = self.clock.now_ms();
367
368 let declarations = self.declarations()?;
369 // Composites are settled after their members, so a gate can be
370 // satisfied in the same pass that completes it rather than waiting a
371 // whole heartbeat. Declaration order is newest-first and says nothing
372 // about dependency, so the split is explicit.
373 let (composites, members): (Vec<_>, Vec<_>) =
374 declarations.into_iter().partition(|(_, t)| t.kind == TriggerKind::Composite);
375
376 for (hash, trigger) in members.into_iter().chain(composites) {
377 if opts.only.as_ref().is_some_and(|only| only != &hash) {
378 continue;
379 }
380 if !trigger.enabled {
381 report.skipped_not_due += 1;
382 continue;
383 }
384 // Checked BEFORE dueness, so an unusable declaration is reported as
385 // what it is rather than disappearing into `not due` — where it
386 // looks exactly like a healthy trigger waiting its turn.
387 if let Err(why) = schedule::validate(&trigger) {
388 report.unusable += 1;
389 report.errors.push(format!("{}: {why}", short(&hash, 12)));
390 continue;
391 }
392 let stored = self
393 .facade
394 .with_store(|m| m.trigger_state(&hash))
395 .map_err(|e| TriggerError::Storage { detail: e.to_string() })?;
396 let (state, raw) = match stored {
397 Some((s, r)) => (s, Some(r)),
398 None => (TriggerState::default(), None),
399 };
400
401 if state.paused {
402 report.skipped_paused += 1;
403 continue;
404 }
405 // An absolute schedule seen for the first time: record when it
406 // will be due rather than re-deriving it every pass, so `trigger
407 // status` can say when it next fires and the comparison afterwards
408 // is a plain timestamp check.
409 if state.next_due_at.is_none() && !state.exhausted {
410 if let Some(first) = schedule::initial_due(&trigger, now)? {
411 let mut seeded = state.clone();
412 seeded.next_due_at = Some(first);
413 let _ = self
414 .facade
415 .with_store(|m| m.put_trigger_state(&hash, raw.as_deref(), &seeded))
416 .map_err(|e| TriggerError::Storage { detail: e.to_string() })?;
417 report.skipped_not_due += 1;
418 continue;
419 }
420 }
421 if !schedule::is_due(&trigger, &state, now)? {
422 report.skipped_not_due += 1;
423 continue;
424 }
425 if state.leased(now) {
426 // The normal steady state with several nodes on one heartbeat,
427 // not a problem.
428 report.skipped_locked += 1;
429 continue;
430 }
431 if opts.dry_run {
432 report.claimed += 1;
433 continue;
434 }
435
436 match self.fire(&hash, &trigger, state, raw.as_deref(), now, opts) {
437 // Lost the claim between deciding it was free and taking it.
438 // Normal with several nodes on one heartbeat, and it must not
439 // be counted as work done.
440 Ok(outcome) if outcome.skipped_locked => report.skipped_locked += 1,
441 Ok(outcome) => {
442 if !outcome.fired_items.is_empty() {
443 if let Err(e) = self.record_for_composites(&hash, &outcome.fired_items, now)
444 {
445 report.errors.push(e.to_string());
446 }
447 }
448 report.claimed += 1;
449 report.items += outcome.items;
450 report.duplicates += outcome.duplicates;
451 report.runs_started += outcome.runs_started;
452 report.ingested += outcome.ingested;
453 report.unidentifiable += outcome.unidentifiable;
454 for why in &outcome.failures {
455 report.errors.push(format!("{hash}: {why}"));
456 }
457 if outcome.draining {
458 report.draining.push(hash.clone());
459 }
460 }
461 Err(e) => {
462 // One broken trigger must not stop the others.
463 report.errors.push(e.to_string());
464 }
465 }
466 }
467 Ok(report)
468 }
469
470 fn fire(
471 &self,
472 hash: &str,
473 trigger: &Trigger,
474 state: TriggerState,
475 raw: Option<&str>,
476 now: i64,
477 opts: &EvalOptions,
478 ) -> Result<FireOutcome> {
479 // Claim. Losing means another evaluator got here first.
480 let mut claimed = state.clone();
481 claimed.fence = claimed.fence.wrapping_add(1);
482 claimed.claimed_by = Some(opts.node.clone());
483 claimed.lease_until = Some(now + opts.lease.as_millis() as i64);
484 let won = self
485 .facade
486 .with_store(|m| m.put_trigger_state(hash, raw, &claimed))
487 .map_err(|e| TriggerError::Storage { detail: e.to_string() })?;
488 if !won {
489 return Ok(FireOutcome { skipped_locked: true, ..Default::default() });
490 }
491 // What a later release must present as `expected`.
492 let claimed_raw = self
493 .facade
494 .with_store(|m| m.trigger_state(hash))
495 .map_err(|e| TriggerError::Storage { detail: e.to_string() })?
496 .map(|(_, r)| r);
497
498 let result = self.collect_and_start(hash, trigger, &claimed, now, opts);
499
500 // Release under the fence, whatever happened.
501 let mut released = claimed.clone();
502 released.claimed_by = None;
503 released.lease_until = None;
504 match &result {
505 Ok(outcome) => {
506 released.last_fired_at = Some(now);
507 released.consecutive_failures = 0;
508 released.last_error = None;
509 if let Some(c) = &outcome.cursor {
510 released.cursor = Some(c.clone());
511 }
512 if let Some(c) = outcome.op_cursor {
513 released.op_cursor = Some(c);
514 }
515 // A satisfied gate consumes its correlation key. Leaving it
516 // would re-fire the same set on every pass; Flink calls this
517 // the after-match skip strategy, and the conservative choice —
518 // clear only the key that fired — is the one that cannot
519 // swallow an unrelated in-flight match.
520 for k in &outcome.settled_keys {
521 released.partials.remove(k);
522 }
523 prune_partials(&mut released, trigger, now);
524 released.exhausted = schedule::exhausts_after(trigger, now);
525 released.next_due_at = if outcome.draining {
526 // A backlog drains at once rather than waiting out the
527 // interval — a cold start should not take a day.
528 Some(now)
529 } else {
530 schedule::advance_after_firing(trigger, now, now)?
531 };
532 }
533 Err(e) => {
534 released.consecutive_failures = claimed.consecutive_failures.saturating_add(1);
535 released.last_error = Some(e.to_string());
536 released.next_due_at =
537 Some(schedule::backoff_until(trigger, released.consecutive_failures, now));
538 }
539 }
540 let released_ok = self
541 .facade
542 .with_store(|m| m.put_trigger_state(hash, claimed_raw.as_deref(), &released))
543 .map_err(|e| TriggerError::Storage { detail: e.to_string() })?;
544 if !released_ok {
545 // Our lease expired mid-firing and someone else took over. Their
546 // state stands; ours is refused. The work we did is not lost — the
547 // runs we started are journaled and idempotent — but we must not
548 // write back over the new owner.
549 self.journal(hash, trigger, &FireOutcome::default(), Some("claim lost"), now)?;
550 return Err(TriggerError::ClaimLost { trigger: hash.to_string() });
551 }
552
553 let outcome = result?;
554 self.journal(hash, trigger, &outcome, None, now)?;
555 Ok(outcome)
556 }
557
558 /// Get the items for this firing and start a run per item.
559 fn collect_and_start(
560 &self,
561 hash: &str,
562 trigger: &Trigger,
563 state: &TriggerState,
564 now: i64,
565 opts: &EvalOptions,
566 ) -> Result<FireOutcome> {
567 let mut outcome = FireOutcome::default();
568
569 let items: Vec<PollItem> = match trigger.kind {
570 TriggerKind::Polling => {
571 let response = self.poll(hash, trigger, state, opts)?;
572 outcome.cursor = response.cursor.clone();
573 outcome.draining = response.more;
574 // First contact seeds the cursor and fires nothing. Otherwise
575 // declaring a mailbox trigger starts a run for every message in
576 // history — Zapier's priming poll, and for the same reason.
577 if state.cursor.is_none() {
578 outcome.seeded = true;
579 return Ok(outcome);
580 }
581 response.items
582 }
583 // A clocked trigger with no external source fires once per
584 // occurrence, carrying the occurrence itself as the item.
585 TriggerKind::Interval | TriggerKind::Schedule | TriggerKind::Once => {
586 vec![PollItem { id: now.to_string(), payload: serde_json::json!({ "at_ms": now }) }]
587 }
588 TriggerKind::Memory => {
589 // Recorded so the journal says "seeded" rather than leaving a
590 // firing that produced nothing looking like a quiet memory.
591 outcome.seeded = state.op_cursor.is_none();
592 let (found, cursor) = self.scan_memory(hash, trigger, state, opts)?;
593 outcome.op_cursor = Some(cursor);
594 found
595 }
596 TriggerKind::Composite => {
597 let keys = self.satisfied_keys(hash, trigger, now)?;
598 outcome.settled_keys = keys.clone();
599 keys.into_iter()
600 .map(|k| PollItem {
601 // The correlation value IS the identity: one firing per
602 // correlated set, however many members contributed.
603 id: k.clone(),
604 payload: serde_json::json!({ "correlation": k }),
605 })
606 .collect()
607 }
608 // These do not poll. The host owns the listener and hands the
609 // payload to `deliver`, which fires them on the same idempotency
610 // terms; an evaluation pass simply has nothing to do for one.
611 TriggerKind::Webhook | TriggerKind::Manual => Vec::new(),
612 };
613
614 outcome.items = items.len();
615 for item in items {
616 let Some(value) = dedup_value(&item, &trigger.dedup_key) else {
617 outcome.unidentifiable += 1;
618 continue;
619 };
620 outcome.fired_items.push(item.payload.clone());
621 let run_id = run_id_for(hash, trigger.connector.as_deref(), &value);
622 match self.start_run(trigger, &run_id, &item, hash) {
623 StartOutcome::Started => outcome.runs_started += 1,
624 StartOutcome::Ingested => outcome.ingested += 1,
625 StartOutcome::Duplicate => outcome.duplicates += 1,
626 StartOutcome::Failed(why) => outcome.failures.push(why),
627 }
628 }
629 Ok(outcome)
630 }
631
632 /// Note a member firing against every composite that declares it.
633 ///
634 /// The correlation value comes from the composite's own `correlate`
635 /// pointer applied to the member's item — so two composites may correlate
636 /// the same member on different fields, which is why this is resolved per
637 /// composite rather than once per member.
638 fn record_for_composites(
639 &self,
640 member: &str,
641 items: &[serde_json::Value],
642 now: i64,
643 ) -> Result<()> {
644 for (chash, composite) in self.declarations()? {
645 let Some(alias) = composite.member_alias(member).map(str::to_string) else {
646 continue;
647 };
648 if composite.kind != TriggerKind::Composite {
649 continue;
650 }
651 let (mut state, raw) = self
652 .facade
653 .with_store(|m| m.trigger_state(&chash))
654 .map_err(|e| TriggerError::Storage { detail: e.to_string() })?
655 .map(|(s, r)| (s, Some(r)))
656 .unwrap_or_default();
657
658 for item in items {
659 // No correlate pointer means one global match — every member
660 // firing counts toward the same gate.
661 let key = match &composite.correlate {
662 Some(ptr) => match item.pointer(ptr) {
663 Some(serde_json::Value::String(s)) => s.clone(),
664 Some(other) => other.to_string(),
665 // An item that cannot be correlated cannot join a
666 // correlated gate. Dropping it is right: attributing it
667 // to an arbitrary key would pair unrelated work.
668 None => continue,
669 },
670 None => String::new(),
671 };
672 let entry = state.partials.entry(key).or_insert_with(|| {
673 areev_store::PartialMatch { members: Default::default(), started_ms: now }
674 });
675 // Recorded under the ALIAS, because that is what the gate
676 // expression names.
677 entry.members.insert(alias.clone(), now);
678 }
679 prune_partials(&mut state, &composite, now);
680 self.facade
681 .with_store(|m| m.put_trigger_state(&chash, raw.as_deref(), &state))
682 .map_err(|e| TriggerError::Storage { detail: e.to_string() })?;
683 }
684 Ok(())
685 }
686
687 /// Which correlation keys currently satisfy this composite's gate.
688 fn satisfied_keys(&self, hash: &str, trigger: &Trigger, now: i64) -> Result<Vec<String>> {
689 let predicate = trigger
690 .predicate
691 .as_ref()
692 .ok_or_else(|| TriggerError::Malformed {
693 what: format!("composite trigger {hash} has no predicate"),
694 })
695 .and_then(crate::predicate::from_value)?;
696
697 // Refuse a gate that names a member the declaration does not carry: it
698 // could never be satisfied, so the trigger would be silently dead.
699 for referenced in crate::predicate::referenced_members(&predicate) {
700 if !trigger.members.contains_key(&referenced) {
701 return Err(TriggerError::UnknownMember {
702 trigger: hash.to_string(),
703 member: referenced,
704 });
705 }
706 }
707
708 let (state, _) = self
709 .facade
710 .with_store(|m| m.trigger_state(hash))
711 .map_err(|e| TriggerError::Storage { detail: e.to_string() })?
712 .unwrap_or_default();
713
714 let mut out = Vec::new();
715 for (key, partial) in &state.partials {
716 if let Some(w) = trigger.window_ms {
717 if now - partial.started_ms > w {
718 continue;
719 }
720 }
721 let fired: Vec<String> = partial.members.keys().cloned().collect();
722 if crate::predicate::gate_satisfied(&predicate, &fired) {
723 out.push(key.clone());
724 }
725 }
726 Ok(out)
727 }
728
729 /// Grains matching the predicate that appeared since the stored op cursor.
730 ///
731 /// Built on the op-log rather than a scan: `changes_since` is an indexed
732 /// primary-key range read with a monotonic exclusive cursor, so cost is
733 /// proportional to what changed rather than to how much the memory holds.
734 ///
735 /// Considered and rejected: `PRAGMA data_version` as a cheap "did anything
736 /// change" pre-check. It is documented as unchanged for commits made on the
737 /// same connection — and Areev is single-writer with one connection, so our
738 /// own writes are exactly what it cannot see.
739 fn scan_memory(
740 &self,
741 hash: &str,
742 trigger: &Trigger,
743 state: &TriggerState,
744 opts: &EvalOptions,
745 ) -> Result<(Vec<PollItem>, i64)> {
746 let predicate = trigger
747 .predicate
748 .as_ref()
749 .ok_or_else(|| TriggerError::Malformed {
750 what: format!("memory trigger {hash} has no predicate"),
751 })
752 .and_then(crate::predicate::from_value)?;
753
754 // First contact seeds at the head of the log and matches nothing.
755 // Otherwise declaring a memory trigger on an established memory fires
756 // once for every historical grain that matches — the same reason a
757 // polling trigger primes rather than replaying a mailbox.
758 let Some(after) = state.op_cursor else {
759 let head = self
760 .facade
761 .with_store(|m| m.head_op_seq())
762 .map_err(|e| TriggerError::Storage { detail: e.to_string() })?;
763 return Ok((Vec::new(), head));
764 };
765 let ops = self
766 .facade
767 .with_store(|m| m.changes_since(after, opts.max_items))
768 .map_err(|e| TriggerError::Storage { detail: e.to_string() })?;
769
770 let mut items = Vec::new();
771 let mut cursor = after;
772 for op in ops {
773 cursor = cursor.max(op.op_seq);
774 // Additions and supersessions both mean "this is true now".
775 // A tombstone means the grain is gone, so there is nothing to match.
776 if op.op == areev_store::OP_FORGET {
777 continue;
778 }
779 let Ok(grain) = self.facade.with_store(|m| m.get(&op.hash)) else {
780 // A grain that vanished between the op-log read and the fetch
781 // (erasure, retention) is not an error — it simply no longer
782 // matches anything.
783 continue;
784 };
785 if !crate::predicate::grain_matches(&grain, &predicate) {
786 continue;
787 }
788 let hex = op.hash.to_hex();
789 items.push(PollItem {
790 // The content address IS the identity: the same grain can never
791 // fire twice, and two evaluators seeing it agree without
792 // coordinating.
793 id: hex.clone(),
794 payload: serde_json::json!({
795 "hash": hex,
796 "grain_type": grain.grain_type.as_str(),
797 "op_seq": op.op_seq,
798 }),
799 });
800 }
801 Ok((items, cursor))
802 }
803
804 fn poll(
805 &self,
806 hash: &str,
807 trigger: &Trigger,
808 state: &TriggerState,
809 opts: &EvalOptions,
810 ) -> Result<PollResponse> {
811 let connector_name = trigger.connector.as_deref().unwrap_or_default();
812 let Some(exec) = &self.connector else {
813 return Err(TriggerError::NoConnector {
814 trigger: hash.to_string(),
815 connector: connector_name.to_string(),
816 });
817 };
818 // Start a broker for this call, scoped to this trigger's allowlist. The
819 // connector is handed its URL — never a token — so a compromised
820 // connector has nothing to exfiltrate and nowhere undeclared to send
821 // it. The broker stops when this call returns.
822 let policy = areev_run::EgressPolicy::from_config(trigger.config.as_ref())
823 .map_err(|what| TriggerError::Malformed { what })?;
824 // One connector runs per pass, so there is no second caller to tell it
825 // apart from: a default grant covers it. Its methods are whatever the
826 // connector needs to poll, and the credentials are the ones this host
827 // configured — naming one it was not given still fails.
828 let grants = areev_run::EgressGrants::new().default_for_all(
829 self.credentials.keys().fold(
830 areev_run::CallerGrant::new()
831 .method("GET")
832 .method("POST")
833 .method("PUT")
834 .method("PATCH")
835 .method("DELETE"),
836 |g, name| g.credential(name),
837 ),
838 );
839 let broker = areev_run::Broker::start(
840 policy,
841 self.credentials.clone(),
842 grants,
843 "TRG-E009",
844 )
845 .map_err(|detail| TriggerError::Storage { detail })?;
846
847 let request = PollRequest {
848 trigger: hash,
849 connector: connector_name,
850 scope: trigger.scope.as_deref(),
851 cursor: state.cursor.as_deref(),
852 max_items: opts.max_items,
853 config: trigger.config.as_ref(),
854 };
855 let payload = serde_json::to_value(&request).map_err(|e| TriggerError::ConnectorFailed {
856 trigger: hash.to_string(),
857 detail: format!("serialize request: {e}"),
858 })?;
859 // The idempotency key ties this call to this occurrence, so a connector
860 // that deduplicates on it sees a retry as a retry.
861 let idem = format!("{hash}:{}", state.fence);
862 // The connector reads AREEV_EGRESS_URL out of its environment. The
863 // spawn seam scrubs whatever `--passphrase-env` and `--token-env` name,
864 // so this is the only network affordance it has from us.
865 //
866 // The variable is PROCESS-GLOBAL, and `HostToolExecutor::execute` has no
867 // per-call environment, so two evaluators in one process would race:
868 // one could hand its broker's URL to the other's connector, pointing it
869 // at the wrong allowlist. The lock makes the set→spawn→unset window
870 // exclusive. Production runs one evaluator per process, so this
871 // serialises nothing that was parallel; it closes a hazard that only
872 // exists in-process (and that our own threaded tests could reach).
873 static EGRESS_ENV: std::sync::Mutex<()> = std::sync::Mutex::new(());
874 let result = {
875 let _guard = EGRESS_ENV.lock().unwrap_or_else(|e| e.into_inner());
876 std::env::set_var("AREEV_EGRESS_URL", broker.url());
877 // The broker authenticates its callers: loopback is not an
878 // authorization, and without the token every call would 401.
879 if let Some(t) = broker.token_for(connector_name) {
880 std::env::set_var("AREEV_EGRESS_TOKEN", t);
881 }
882 let r = exec.execute(connector_name, hash, &payload, &idem);
883 std::env::remove_var("AREEV_EGRESS_URL");
884 std::env::remove_var("AREEV_EGRESS_TOKEN");
885 r
886 };
887
888 // Surface a refused destination as its own error rather than letting it
889 // read as a generic connector failure — a connector reaching somewhere
890 // undeclared is a different problem from one that crashed.
891 let refused = broker.refusals();
892 if !refused.is_empty() {
893 return Err(TriggerError::EgressRefused {
894 trigger: hash.to_string(),
895 host: refused
896 .iter()
897 .map(|r| format!("{} ({})", r.destination, r.reason))
898 .collect::<Vec<_>>()
899 .join(", "),
900 });
901 }
902 match result {
903 areev_run::ExecResult::Ok(v) => {
904 serde_json::from_value(v).map_err(|e| TriggerError::ConnectorFailed {
905 trigger: hash.to_string(),
906 detail: format!("response is not a poll response: {e}"),
907 })
908 }
909 areev_run::ExecResult::Err { detail, .. } => {
910 Err(TriggerError::ConnectorFailed { trigger: hash.to_string(), detail })
911 }
912 }
913 }
914
915 /// Record the item as an Event and start the bound workflow.
916 fn start_run(
917 &self,
918 trigger: &Trigger,
919 run_id: &str,
920 item: &PollItem,
921 trigger_hash: &str,
922 ) -> StartOutcome {
923 // Idempotency has to hold whether or not a runtime is wired in. With a
924 // starter, the runtime's duplicate-run-id refusal is the guard; without
925 // one, nothing else would stop the same item being re-ingested on every
926 // poll forever, because an Event's `created_at` differs per firing and
927 // so content addressing does not collapse them.
928 let seen = self
929 .facade
930 .with_store(|m| m.run_grains(&self.ns, run_id, 0, 1))
931 .map(|g| !g.is_empty())
932 .unwrap_or(false);
933 if seen {
934 return StartOutcome::Duplicate;
935 }
936
937 let mut event = Event::new(&item.payload.to_string())
938 .namespace(&self.ns)
939 .extra_field("trigger", serde_json::json!(trigger_hash))
940 .extra_field("run_id", serde_json::json!(run_id));
941 if let Some(c) = &trigger.connector {
942 event = event.extra_field("connector", serde_json::json!(c));
943 }
944 if let Err(e) = self.facade.with_store(|m| m.add(&event)) {
945 return StartOutcome::Failed(format!("ingest: {e}"));
946 }
947 let Some(starter) = &self.starter else {
948 // Ingest-only: the item is recorded, nothing is executed. Reported
949 // as ingested rather than as a run, because claiming a run started
950 // when none did would make the report lie.
951 return StartOutcome::Ingested;
952 };
953 let mut input = serde_json::json!({
954 "trigger": trigger_hash,
955 "connector": trigger.connector,
956 "scope": trigger.scope,
957 "item": item.payload,
958 });
959 // Declared context (#85): the EVALUATOR assembles it, because it is
960 // the one party that already holds the memory — on the embedded
961 // backend a tool inside the run cannot open the file its own run
962 // locks. Fail closed: a trigger that declared context must not fire
963 // blind, so a missing query or a failed read refuses the firing (the
964 // evaluator's normal retry/backoff applies) rather than starting a
965 // run without the context its declaration promised.
966 if let Some(name) = &trigger.context_query {
967 match self.assemble_context(name) {
968 Ok(ctx) => {
969 input["context"] = ctx;
970 }
971 Err(why) => {
972 return StartOutcome::Failed(format!("context_query \"{name}\": {why}"));
973 }
974 }
975 }
976 match starter.start(&trigger.workflow, run_id, input) {
977 StartResult::Started => StartOutcome::Started,
978 StartResult::Duplicate => StartOutcome::Duplicate,
979 StartResult::Failed(why) => StartOutcome::Failed(why),
980 }
981 }
982
983 /// Run the trigger's saved query (`RUN "name"`) against the facade this
984 /// evaluator already holds and return the result payload. The executor
985 /// re-validates the saved body as read-only at execution, and the
986 /// destructive cap is off — a context query can never mutate.
987 fn assemble_context(&self, name: &str) -> std::result::Result<serde_json::Value, String> {
988 // The name travels inside a CAL string literal; refuse anything that
989 // could not be a saved-query name rather than escaping it.
990 if name.is_empty()
991 || !name.chars().all(|c| c.is_ascii_alphanumeric() || matches!(c, '_' | '-' | '.'))
992 {
993 return Err("not a saved-query name (allowed: [A-Za-z0-9_.-])".into());
994 }
995 let ex = areev_cal::CalExecutor::new(areev_cal::CalExecutorConfig {
996 allow_destructive_ops: false,
997 ..areev_cal::CalExecutorConfig::default()
998 });
999 let res = ex
1000 .execute(&format!("RUN \"{name}\""), self.facade.as_ref())
1001 .map_err(|e| e.to_string())?;
1002 serde_json::to_value(&res.result).map_err(|e| e.to_string())
1003 }
1004
1005 /// One Observation per firing: what fired, what it produced, what it
1006 /// skipped. An unfired trigger is invisible otherwise.
1007 fn journal(
1008 &self,
1009 hash: &str,
1010 trigger: &Trigger,
1011 outcome: &FireOutcome,
1012 note: Option<&str>,
1013 now: i64,
1014 ) -> Result<()> {
1015 let mut obs = Observation::new("areev:trigger", "trigger:firing")
1016 .namespace(TRIGGER_NS)
1017 .created_at(now)
1018 .extra_field("trigger", serde_json::json!(hash))
1019 .extra_field("kind", serde_json::json!(trigger.kind.as_str()))
1020 .extra_field("workflow", serde_json::json!(trigger.workflow))
1021 .extra_field("items", serde_json::json!(outcome.items))
1022 .extra_field("runs_started", serde_json::json!(outcome.runs_started))
1023 .extra_field("duplicates", serde_json::json!(outcome.duplicates));
1024 // Without these, a firing where every item lacked the dedup key reads
1025 // as "items 5, runs_started 0, duplicates 0" -- and that last zero
1026 // actively misleads, saying the items were not skipped as duplicates
1027 // without saying why they were skipped at all. Emitted only when
1028 // non-zero, so an ordinary firing stays as small as it was.
1029 if outcome.unidentifiable > 0 {
1030 obs = obs.extra_field("unidentifiable", serde_json::json!(outcome.unidentifiable));
1031 }
1032 if outcome.ingested > 0 {
1033 obs = obs.extra_field("ingested", serde_json::json!(outcome.ingested));
1034 }
1035 if !outcome.failures.is_empty() {
1036 obs = obs.extra_field("failures", serde_json::json!(outcome.failures));
1037 }
1038 if outcome.seeded {
1039 obs = obs.extra_field("seeded", serde_json::json!(true));
1040 }
1041 if let Some(n) = note {
1042 obs = obs.extra_field("note", serde_json::json!(n));
1043 }
1044 self.facade
1045 .with_store(|m| m.add_if_novel(&obs))
1046 .map(|_| ())
1047 .map_err(|e| TriggerError::Storage { detail: e.to_string() })
1048 }
1049}
1050
1051/// Drop partial matches past their window.
1052///
1053/// Without this, a composite that never completes accumulates keys forever and
1054/// a stale half-match eventually pairs with unrelated work — the exact failure
1055/// Argo Events' reset cron exists to paper over.
1056fn prune_partials(state: &mut TriggerState, trigger: &Trigger, now: i64) {
1057 let Some(window) = trigger.window_ms else {
1058 return;
1059 };
1060 state.partials.retain(|_, p| now - p.started_ms <= window);
1061}
1062
1063#[derive(Debug, Default, Clone)]
1064struct FireOutcome {
1065 items: usize,
1066 runs_started: usize,
1067 ingested: usize,
1068 duplicates: usize,
1069 unidentifiable: usize,
1070 failures: Vec<String>,
1071 cursor: Option<String>,
1072 /// The payloads this firing produced, so a composite can pull its
1073 /// correlation value out of them.
1074 fired_items: Vec<serde_json::Value>,
1075 /// Correlation keys a composite consumed, cleared on release.
1076 settled_keys: Vec<String>,
1077 op_cursor: Option<i64>,
1078 draining: bool,
1079 seeded: bool,
1080 skipped_locked: bool,
1081}
1082
1083enum StartOutcome {
1084 Started,
1085 /// Recorded but not executed — no runtime is wired in.
1086 Ingested,
1087 Duplicate,
1088 Failed(String),
1089}
1090
1091/// The run id for one firing of one item.
1092///
1093/// Derived from `(trigger, connector, dedup value)`, so the same item seen
1094/// twice — connector replay, overlapping cursors, two nodes racing a lease
1095/// boundary — produces the same id. `areev run start` refuses a duplicate, so
1096/// the second attempt is a recorded skip rather than a second run. At-most-once
1097/// firing with no lease, no fencing token, and no clock assumption.
1098///
1099/// The connector is part of the identity because CloudEvents made that
1100/// normative for the same reason: `source` + `id` is unique, `id` alone is only
1101/// unique within a producer. Two connectors sharing an id space must not
1102/// collide.
1103pub fn run_id_for(trigger_hash: &str, connector: Option<&str>, dedup_value: &str) -> String {
1104 let mut h = Sha256::new();
1105 h.update(b"areev-trigger:v1");
1106 h.update([0x1f]);
1107 h.update(connector.unwrap_or_default().as_bytes());
1108 h.update([0x1f]);
1109 h.update(dedup_value.as_bytes());
1110 let digest = hex(&h.finalize());
1111 format!("{}-{}", short(trigger_hash, 8), short(&digest, 12))
1112}
1113
1114fn hex(bytes: &[u8]) -> String {
1115 bytes.iter().map(|b| format!("{b:02x}")).collect()
1116}
1117
1118#[cfg(test)]
1119mod tests {
1120 use super::*;
1121
1122 #[test]
1123 fn a_run_id_is_stable_for_the_same_item() {
1124 let a = run_id_for("abcdef1234", Some("gmail"), "m1");
1125 let b = run_id_for("abcdef1234", Some("gmail"), "m1");
1126 assert_eq!(a, b, "the same item must always mint the same id");
1127 }
1128
1129 #[test]
1130 fn different_items_get_different_ids() {
1131 let a = run_id_for("abcdef1234", Some("gmail"), "m1");
1132 let b = run_id_for("abcdef1234", Some("gmail"), "m2");
1133 assert_ne!(a, b);
1134 }
1135
1136 #[test]
1137 fn the_connector_is_part_of_the_identity() {
1138 // CloudEvents' rule: `id` alone is unique only within a producer, so two
1139 // connectors with a shared id space must not collide.
1140 let a = run_id_for("abcdef1234", Some("gmail"), "1");
1141 let b = run_id_for("abcdef1234", Some("stripe"), "1");
1142 assert_ne!(a, b);
1143 }
1144
1145 #[test]
1146 fn the_id_names_its_trigger_for_a_human_reading_a_run_list() {
1147 let id = run_id_for("abcdef1234567890", Some("gmail"), "m1");
1148 assert!(id.starts_with("abcdef12"), "{id}");
1149 assert!(id.len() < 32, "short enough to read: {id}");
1150 }
1151
1152 #[test]
1153 fn a_short_trigger_hash_does_not_panic() {
1154 let id = run_id_for("ab", None, "x");
1155 assert!(id.starts_with("ab-"));
1156 }
1157}