Skip to main content

aion_server/stream/
selector.rs

1//! Server-side selector filtering for filtered subscriptions.
2//!
3//! `FilteredSubscription` advertises optional `workflow_type` and `status`
4//! selectors. The engine's `EventFilter` has no type or status dimension, so
5//! the selection runs at the socket seam, after the namespace gate proved
6//! ownership and resolved the workflow's recorded type from the same durable
7//! read.
8//!
9//! Selector semantics (documented in `docs/API.md`):
10//!
11//! - `workflow_type` matches when the event's workflow has that recorded type
12//!   at the time the namespace gate resolved it: the initial durable read
13//!   returns the head-of-history `WorkflowStarted` type at read time, which on
14//!   a continue-as-new chain can briefly run ahead of an older delivered event
15//!   (a one-event-loop forward-skew window) until the stream's own
16//!   `WorkflowStarted` refresh self-heals the cached type. A workflow whose
17//!   history records no started run never matches a type selector.
18//! - `status` matches per event kind: each terminal lifecycle event matches
19//!   exactly its projected status (`WorkflowCompleted` → `Completed`,
20//!   `WorkflowFailed` → `Failed`, `WorkflowCancelled` → `Cancelled`,
21//!   `WorkflowTimedOut` → `TimedOut`, `WorkflowContinuedAsNew` →
22//!   `ContinuedAsNew`); every other LIFECYCLE event — including
23//!   `WorkflowStarted` — matches `Running`. A LABEL-ONLY
24//!   `SearchAttributesUpdated` (a rename) is not a lifecycle event (#211) and
25//!   matches ANY status selector, because a workflow's display name changes in
26//!   every status and both forcing it into `Running` and withholding it from
27//!   the other buckets would be wrong. The `SearchAttributesUpdated` carrying a
28//!   run's PLACEMENT is recorded atomically with `WorkflowStarted` and matches
29//!   `Running` with it, so a terminal-status subscriber is not sent an
30//!   attribute frame for every workflow starting in the namespace.
31//! - When both selectors are present they AND together.
32
33use aion_core::{Event, WorkflowStatus};
34
35use crate::namespace::{NAMESPACE_ATTRIBUTE, TASK_QUEUE_ATTRIBUTE};
36
37/// Validated subscription selectors applied before frame encoding.
38#[derive(Clone, Debug, Default, Eq, PartialEq)]
39pub struct SubscriptionSelector {
40    /// Deliver only events of workflows with this recorded type.
41    pub workflow_type: Option<String>,
42    /// Deliver only events whose kind projects to this status.
43    pub status: Option<WorkflowStatus>,
44}
45
46impl SubscriptionSelector {
47    /// Selector that admits every event (per-workflow and firehose
48    /// subscriptions carry no selectors).
49    #[must_use]
50    pub const fn unrestricted() -> Self {
51        Self {
52            workflow_type: None,
53            status: None,
54        }
55    }
56
57    /// Decide whether an event passes the selector. `workflow_type` is the
58    /// event's workflow's recorded type as resolved by the namespace gate.
59    #[must_use]
60    pub fn matches(&self, event: &Event, workflow_type: Option<&str>) -> bool {
61        if let Some(selected_type) = &self.workflow_type {
62            // No recorded type (no started run) can never satisfy a type
63            // selector — absence is not a wildcard.
64            if workflow_type != Some(selected_type.as_str()) {
65                return false;
66            }
67        }
68        if let Some(selected_status) = self.status {
69            // `None` is an event with NO lifecycle meaning (a rename): it
70            // passes every status selector rather than being forced into one.
71            if event_status(event).is_some_and(|status| status != selected_status) {
72                return false;
73            }
74        }
75        true
76    }
77}
78
79/// The lifecycle status a single event's kind projects, or `None` when the
80/// event carries no lifecycle meaning at all.
81///
82/// Terminal lifecycle events project exactly their terminal status; the rest
83/// belong to a running workflow at the moment they were recorded — with ONE
84/// exception, which is why this returns an `Option` rather than a status.
85///
86/// A LABEL-ONLY `SearchAttributesUpdated` (#211: a rename) has no lifecycle
87/// meaning, and the label belongs to the WORKFLOW rather than to any one run —
88/// it is folded over the whole history, so a completed run's row shows a name as
89/// legitimate as a running one's, and a rename recorded now retitles every run
90/// of that workflow at once. Projecting it as `Running`, as this function once
91/// did, was wrong in both directions: a `Running` subscriber received the rename
92/// of a finished run, and a `Completed` subscriber never received renames of the
93/// runs it was actually displaying, so the name it showed went stale until a
94/// refetch. Status selectors are about LIFECYCLE, so an event with no lifecycle
95/// meaning answers `None` and passes every status selector instead of being
96/// forced into a bucket it does not belong to.
97///
98/// That exemption is scoped to label-only updates, and deliberately no wider.
99/// The engine also records a `SearchAttributesUpdated` in the same ATOMIC batch
100/// as `WorkflowStarted` (`record_workflow_started_with_attributes`), stamping
101/// the run's placement — so on a server-embedded engine every start emits one.
102/// Exempting that one too would hand a `status=Completed` subscriber an
103/// attribute frame for every workflow STARTING in the namespace: a delivery
104/// widening with no rename to justify it, over what is often the highest-volume
105/// event class there is. It accompanies a start, so it projects `Running`
106/// exactly like the `WorkflowStarted` it ships with. See
107/// [`is_start_time_stamp`] for how the two are told apart.
108///
109/// Returning `Option` rather than special-casing the caller is deliberate: it
110/// puts the exception in the type, so a future event kind with no lifecycle
111/// meaning cannot be given a wrong status by default.
112fn event_status(event: &Event) -> Option<WorkflowStatus> {
113    match event {
114        Event::WorkflowCompleted { .. } => Some(WorkflowStatus::Completed),
115        Event::WorkflowFailed { .. } => Some(WorkflowStatus::Failed),
116        Event::WorkflowCancelled { .. } => Some(WorkflowStatus::Cancelled),
117        Event::WorkflowTimedOut { .. } => Some(WorkflowStatus::TimedOut),
118        Event::WorkflowContinuedAsNew { .. } => Some(WorkflowStatus::ContinuedAsNew),
119        // A pause projects Paused at the moment it is recorded (#204).
120        Event::WorkflowPaused { .. } => Some(WorkflowStatus::Paused),
121        // A LABEL change is not a lifecycle transition (#211): no status. The
122        // start-time placement stamp is not a label change — it ships with the
123        // start, so it keeps the start's status.
124        Event::SearchAttributesUpdated { attributes, .. } => {
125            if is_start_time_stamp(attributes) {
126                Some(WorkflowStatus::Running)
127            } else {
128                None
129            }
130        }
131        Event::WorkflowStarted { .. }
132        // A reopen returns the workflow to Running at the moment it is recorded.
133        | Event::WorkflowReopened { .. }
134        // A resume returns the workflow to Running at the moment it is recorded.
135        | Event::WorkflowResumed { .. }
136        | Event::ActivityScheduled { .. }
137        | Event::ActivityStarted { .. }
138        // A lease names the worker holding an attempt; the run keeps running.
139        | Event::ActivityLeased { .. }
140        | Event::ActivityAdoptionOffered { .. }
141        | Event::ActivityCompleted { .. }
142        | Event::ActivityFailed { .. }
143        // A side channel exhausted its budget; the workflow it warns about
144        // is still running.
145        | Event::ActivityAdvisoryExhausted { .. }
146        | Event::ActivityFallbackRouted { .. }
147        | Event::ActivityCancelled { .. }
148        | Event::TimerStarted { .. }
149        | Event::TimerFired { .. }
150        | Event::TimerCancelled { .. }
151        | Event::WithTimeoutCompleted { .. }
152        | Event::SignalReceived { .. }
153        | Event::SignalSent { .. }
154        | Event::ChildWorkflowStarted { .. }
155        | Event::ChildWorkflowCompleted { .. }
156        | Event::ChildWorkflowFailed { .. }
157        | Event::ChildWorkflowCancelled { .. }
158        | Event::ScheduleCreated { .. }
159        | Event::ScheduleUpdated { .. }
160        | Event::SchedulePaused { .. }
161        | Event::ScheduleResumed { .. }
162        | Event::ScheduleDeleted { .. }
163        | Event::ScheduleTriggered { .. }
164        // Workloop bookkeeping happens on a live loop: a cadence fire, an
165        // iteration close, a hatch, and an unconfirmed-invariant alarm all
166        // leave the run Running. LoopRetired's terminal is the
167        // WorkflowCompleted recorded in the same append, which projects its
168        // own status above.
169        | Event::CadenceFired { .. }
170        | Event::IterationClosed { .. }
171        | Event::LoopRetired { .. }
172        | Event::WorkflowHatched { .. }
173        | Event::InvariantUnconfirmed { .. } => Some(WorkflowStatus::Running),
174    }
175}
176
177/// Whether a `SearchAttributesUpdated` is the PLACEMENT stamp the engine records
178/// atomically with `WorkflowStarted`, rather than a label change.
179///
180/// The two are told apart by the attributes they carry, which is exact rather
181/// than a heuristic because only two sites in the tree record this event:
182///
183/// * `lifecycle::start` stamps the run's placement, and the server's
184///   `start_search_attributes` ALWAYS writes [`NAMESPACE_ATTRIBUTE`] (the task
185///   queue and display name are conditional, the namespace is not);
186/// * `lifecycle::rename` records the display-name attribute and nothing else.
187///
188/// Both placement attributes are start-time-only by construction — the rename
189/// verb cannot write either, and no verb updates a namespace or task queue after
190/// the fact — so an update carrying one is the start stamp and an update
191/// carrying neither is a label change.
192///
193/// A caller embedding the engine directly could record a start whose attributes
194/// carry no placement at all; that stamp reads as a label change, so it answers
195/// `None` and passes every status selector.
196///
197/// Name that honestly: it is a WIDENING, not a preservation. Before #211 this
198/// function did not exist and EVERY `SearchAttributesUpdated` projected
199/// `Running`, so a placement-less start stamp reached only `status=Running`
200/// subscribers; now it reaches all of them. The widening is confined to
201/// DELIVERY — `event_status` feeds subscription filtering only, and
202/// `WorkflowStatus` itself is projected from history elsewhere and is untouched
203/// — and it costs one extra attribute frame to status-filtered subscribers of
204/// an embedded engine that stamps no placement, which the server's own
205/// `start_search_attributes` never does. Narrowing it back would require the
206/// selector to see the whole atomic batch rather than one event, which this
207/// call site cannot; that is the reason it stands, not that nothing changed.
208fn is_start_time_stamp(
209    attributes: &std::collections::HashMap<String, aion_core::SearchAttributeValue>,
210) -> bool {
211    attributes.contains_key(NAMESPACE_ATTRIBUTE) || attributes.contains_key(TASK_QUEUE_ATTRIBUTE)
212}
213
214#[cfg(test)]
215mod tests {
216    use aion_core::{Event, EventEnvelope, Payload, WorkflowId, WorkflowStatus};
217
218    use super::SubscriptionSelector;
219
220    fn envelope(seq: u64) -> EventEnvelope {
221        EventEnvelope {
222            seq,
223            recorded_at: chrono::Utc::now(),
224            workflow_id: WorkflowId::new(uuid::Uuid::from_u128(1)),
225        }
226    }
227
228    fn payload() -> Result<Payload, aion_core::PayloadError> {
229        Payload::from_json(&serde_json::json!({ "label": "x" }))
230    }
231
232    fn signal(seq: u64) -> Result<Event, aion_core::PayloadError> {
233        Ok(Event::SignalReceived {
234            envelope: envelope(seq),
235            name: "ship".to_owned(),
236            payload: payload()?,
237        })
238    }
239
240    fn started(seq: u64) -> Result<Event, aion_core::PayloadError> {
241        Ok(Event::WorkflowStarted {
242            envelope: envelope(seq),
243            workflow_type: "checkout".to_owned(),
244            input: payload()?,
245            run_id: aion_core::RunId::new(uuid::Uuid::from_u128(1)),
246            parent_run_id: None,
247            parent_workflow_id: None,
248            package_version: aion_core::PackageVersion::new("a".repeat(64)),
249        })
250    }
251
252    fn completed(seq: u64) -> Result<Event, aion_core::PayloadError> {
253        Ok(Event::WorkflowCompleted {
254            envelope: envelope(seq),
255            result: payload()?,
256        })
257    }
258
259    fn failed(seq: u64) -> Event {
260        Event::WorkflowFailed {
261            envelope: envelope(seq),
262            error: aion_core::WorkflowError {
263                message: "boom".to_owned(),
264                details: None,
265            },
266        }
267    }
268
269    fn renamed(seq: u64) -> Event {
270        Event::SearchAttributesUpdated {
271            envelope: envelope(seq),
272            workflow_id: WorkflowId::new(uuid::Uuid::from_u128(1)),
273            attributes: std::collections::HashMap::from([(
274                aion_core::DISPLAY_NAME_ATTRIBUTE.to_owned(),
275                aion_core::SearchAttributeValue::String("Nightly settlement".to_owned()),
276            )]),
277        }
278    }
279
280    /// The start-time attribute stamp: what the engine records ATOMICALLY with
281    /// `WorkflowStarted` for every start that carries a namespace, task queue,
282    /// or display name — which, through the server, is every start.
283    fn start_stamp(seq: u64) -> Event {
284        Event::SearchAttributesUpdated {
285            envelope: envelope(seq),
286            workflow_id: WorkflowId::new(uuid::Uuid::from_u128(1)),
287            attributes: std::collections::HashMap::from([
288                (
289                    crate::namespace::NAMESPACE_ATTRIBUTE.to_owned(),
290                    aion_core::SearchAttributeValue::String("default".to_owned()),
291                ),
292                (
293                    crate::namespace::TASK_QUEUE_ATTRIBUTE.to_owned(),
294                    aion_core::SearchAttributeValue::String("settlement".to_owned()),
295                ),
296                (
297                    aion_core::DISPLAY_NAME_ATTRIBUTE.to_owned(),
298                    aion_core::SearchAttributeValue::String("Nightly settlement".to_owned()),
299                ),
300            ]),
301        }
302    }
303
304    /// #211, the OTHER `SearchAttributesUpdated`: the start-time stamp is NOT a
305    /// rename and must not inherit the rename's status-blindness.
306    ///
307    /// `record_workflow_started_with_attributes` appends this event in the same
308    /// atomic batch as `WorkflowStarted`, so on a server-embedded engine every
309    /// start emits one. Passing it to every status selector would hand a
310    /// `status=Completed` subscriber an attribute frame for every workflow
311    /// STARTING in the namespace — a delivery widening far past the rename this
312    /// exemption was written for, and on a busy namespace the highest-volume
313    /// event class there is.
314    ///
315    /// It genuinely accompanies a start, so it projects `Running` like the
316    /// `WorkflowStarted` it ships with.
317    #[test]
318    fn a_start_time_attribute_stamp_stays_in_the_running_bucket() {
319        let running = SubscriptionSelector {
320            workflow_type: None,
321            status: Some(WorkflowStatus::Running),
322        };
323        assert!(
324            running.matches(&start_stamp(2), Some("checkout")),
325            "the start-time stamp accompanies a start, so it belongs to Running"
326        );
327
328        for status in [
329            WorkflowStatus::Completed,
330            WorkflowStatus::Failed,
331            WorkflowStatus::Cancelled,
332            WorkflowStatus::TimedOut,
333            WorkflowStatus::ContinuedAsNew,
334            WorkflowStatus::Paused,
335        ] {
336            let selector = SubscriptionSelector {
337                workflow_type: None,
338                status: Some(status),
339            };
340            assert!(
341                !selector.matches(&start_stamp(2), Some("checkout")),
342                "a status={status:?} subscriber must not receive an attribute frame for every \
343                 workflow STARTING in the namespace"
344            );
345        }
346
347        // CONTROL: the narrowing must not swallow the rename exemption it sits
348        // beside. A label-only update still reaches a terminal subscriber —
349        // without this, deleting the exemption entirely would pass the above.
350        let completed_only = SubscriptionSelector {
351            workflow_type: None,
352            status: Some(WorkflowStatus::Completed),
353        };
354        assert!(
355            completed_only.matches(&renamed(1), Some("checkout")),
356            "a label-only rename must still reach every status subscriber"
357        );
358    }
359
360    /// #211: a rename is a LABEL change, not a lifecycle event, so it reaches
361    /// EVERY status subscriber.
362    ///
363    /// Renames legally record on terminal and paused runs, which broke the old
364    /// "every non-terminal event belongs to a running workflow" premise two
365    /// ways at once: a `Running` subscriber was handed the rename of a
366    /// finished run (wrong bucket), and a `Completed` subscriber never saw
367    /// renames of the very runs it was displaying (a name that goes stale
368    /// until a refetch). Status selectors are about LIFECYCLE, and a label
369    /// change has no lifecycle meaning — so it passes the status arm whatever
370    /// the selector asks for.
371    #[test]
372    fn a_rename_reaches_every_status_subscriber() -> Result<(), Box<dyn std::error::Error>> {
373        for status in [
374            WorkflowStatus::Running,
375            WorkflowStatus::Completed,
376            WorkflowStatus::Failed,
377            WorkflowStatus::Cancelled,
378            WorkflowStatus::TimedOut,
379            WorkflowStatus::ContinuedAsNew,
380            WorkflowStatus::Paused,
381        ] {
382            let selector = SubscriptionSelector {
383                workflow_type: None,
384                status: Some(status),
385            };
386            assert!(
387                selector.matches(&renamed(1), Some("checkout")),
388                "a rename must reach a status={status:?} subscriber"
389            );
390        }
391
392        // CONTROL: bypassing the status arm must not bypass the TYPE arm, and
393        // must not make every other event status-blind either — without these
394        // the assertions above would also pass if `matches` had started
395        // returning `true` unconditionally.
396        let typed = SubscriptionSelector {
397            workflow_type: Some("checkout".to_owned()),
398            status: Some(WorkflowStatus::Completed),
399        };
400        assert!(!typed.matches(&renamed(1), Some("payments")));
401        assert!(!typed.matches(&signal(2)?, Some("checkout")));
402        Ok(())
403    }
404
405    #[test]
406    fn unrestricted_selector_matches_everything() -> Result<(), Box<dyn std::error::Error>> {
407        let selector = SubscriptionSelector::unrestricted();
408
409        assert!(selector.matches(&signal(1)?, None));
410        assert!(selector.matches(&completed(2)?, Some("checkout")));
411        Ok(())
412    }
413
414    #[test]
415    fn type_selector_matches_only_the_recorded_type() -> Result<(), Box<dyn std::error::Error>> {
416        let selector = SubscriptionSelector {
417            workflow_type: Some("checkout".to_owned()),
418            status: None,
419        };
420
421        assert!(selector.matches(&signal(1)?, Some("checkout")));
422        assert!(!selector.matches(&signal(1)?, Some("fulfillment")));
423        assert!(
424            !selector.matches(&signal(1)?, None),
425            "a workflow with no recorded type never matches a type selector"
426        );
427        Ok(())
428    }
429
430    #[test]
431    fn status_selector_matches_per_event_kind() -> Result<(), Box<dyn std::error::Error>> {
432        let running = SubscriptionSelector {
433            workflow_type: None,
434            status: Some(WorkflowStatus::Running),
435        };
436        let completed_only = SubscriptionSelector {
437            workflow_type: None,
438            status: Some(WorkflowStatus::Completed),
439        };
440        let failed_only = SubscriptionSelector {
441            workflow_type: None,
442            status: Some(WorkflowStatus::Failed),
443        };
444
445        // Running matches every non-terminal event, including WorkflowStarted.
446        assert!(running.matches(&started(1)?, Some("checkout")));
447        assert!(running.matches(&signal(2)?, Some("checkout")));
448        assert!(!running.matches(&completed(3)?, Some("checkout")));
449
450        // Each terminal status matches exactly its terminal event kind.
451        assert!(completed_only.matches(&completed(3)?, Some("checkout")));
452        assert!(!completed_only.matches(&failed(3), Some("checkout")));
453        assert!(!completed_only.matches(&signal(2)?, Some("checkout")));
454        assert!(failed_only.matches(&failed(3), Some("checkout")));
455        assert!(!failed_only.matches(&completed(3)?, Some("checkout")));
456        Ok(())
457    }
458
459    fn timed_out(seq: u64) -> Event {
460        Event::WorkflowTimedOut {
461            envelope: envelope(seq),
462            timeout: "workflow".to_owned(),
463        }
464    }
465
466    #[test]
467    fn status_selector_projects_workflow_timed_out_to_timed_out()
468    -> Result<(), Box<dyn std::error::Error>> {
469        // The ops-console stream selector must surface a `WorkflowTimedOut`
470        // terminal as `TimedOut`: a `TimedOut` subscription admits it, a
471        // `Running` subscription rejects it (it is terminal), and a `Failed`
472        // subscription does not confuse it for a failure.
473        let timed_out_only = SubscriptionSelector {
474            workflow_type: None,
475            status: Some(WorkflowStatus::TimedOut),
476        };
477        let running = SubscriptionSelector {
478            workflow_type: None,
479            status: Some(WorkflowStatus::Running),
480        };
481        let failed_only = SubscriptionSelector {
482            workflow_type: None,
483            status: Some(WorkflowStatus::Failed),
484        };
485
486        assert!(timed_out_only.matches(&timed_out(4), Some("checkout")));
487        assert!(!timed_out_only.matches(&completed(3)?, Some("checkout")));
488        assert!(!running.matches(&timed_out(4), Some("checkout")));
489        assert!(!failed_only.matches(&timed_out(4), Some("checkout")));
490        Ok(())
491    }
492
493    #[test]
494    fn combined_selectors_and_together() -> Result<(), Box<dyn std::error::Error>> {
495        let selector = SubscriptionSelector {
496            workflow_type: Some("checkout".to_owned()),
497            status: Some(WorkflowStatus::Completed),
498        };
499
500        assert!(selector.matches(&completed(3)?, Some("checkout")));
501        assert!(
502            !selector.matches(&completed(3)?, Some("fulfillment")),
503            "matching status with mismatched type must not pass"
504        );
505        assert!(
506            !selector.matches(&signal(2)?, Some("checkout")),
507            "matching type with mismatched status must not pass"
508        );
509        Ok(())
510    }
511}