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 | Event::ActivityAdoptionOffered { .. }
139 | Event::ActivityCompleted { .. }
140 | Event::ActivityFailed { .. }
141 // A side channel exhausted its budget; the workflow it warns about
142 // is still running.
143 | Event::ActivityAdvisoryExhausted { .. }
144 | Event::ActivityFallbackRouted { .. }
145 | Event::ActivityCancelled { .. }
146 | Event::TimerStarted { .. }
147 | Event::TimerFired { .. }
148 | Event::TimerCancelled { .. }
149 | Event::WithTimeoutCompleted { .. }
150 | Event::SignalReceived { .. }
151 | Event::SignalSent { .. }
152 | Event::ChildWorkflowStarted { .. }
153 | Event::ChildWorkflowCompleted { .. }
154 | Event::ChildWorkflowFailed { .. }
155 | Event::ChildWorkflowCancelled { .. }
156 | Event::ScheduleCreated { .. }
157 | Event::ScheduleUpdated { .. }
158 | Event::SchedulePaused { .. }
159 | Event::ScheduleResumed { .. }
160 | Event::ScheduleDeleted { .. }
161 | Event::ScheduleTriggered { .. }
162 // Workloop bookkeeping happens on a live loop: a cadence fire, an
163 // iteration close, a hatch, and an unconfirmed-invariant alarm all
164 // leave the run Running. LoopRetired's terminal is the
165 // WorkflowCompleted recorded in the same append, which projects its
166 // own status above.
167 | Event::CadenceFired { .. }
168 | Event::IterationClosed { .. }
169 | Event::LoopRetired { .. }
170 | Event::WorkflowHatched { .. }
171 | Event::InvariantUnconfirmed { .. } => Some(WorkflowStatus::Running),
172 }
173}
174
175/// Whether a `SearchAttributesUpdated` is the PLACEMENT stamp the engine records
176/// atomically with `WorkflowStarted`, rather than a label change.
177///
178/// The two are told apart by the attributes they carry, which is exact rather
179/// than a heuristic because only two sites in the tree record this event:
180///
181/// * `lifecycle::start` stamps the run's placement, and the server's
182/// `start_search_attributes` ALWAYS writes [`NAMESPACE_ATTRIBUTE`] (the task
183/// queue and display name are conditional, the namespace is not);
184/// * `lifecycle::rename` records the display-name attribute and nothing else.
185///
186/// Both placement attributes are start-time-only by construction — the rename
187/// verb cannot write either, and no verb updates a namespace or task queue after
188/// the fact — so an update carrying one is the start stamp and an update
189/// carrying neither is a label change.
190///
191/// A caller embedding the engine directly could record a start whose attributes
192/// carry no placement at all; that stamp reads as a label change, so it answers
193/// `None` and passes every status selector.
194///
195/// Name that honestly: it is a WIDENING, not a preservation. Before #211 this
196/// function did not exist and EVERY `SearchAttributesUpdated` projected
197/// `Running`, so a placement-less start stamp reached only `status=Running`
198/// subscribers; now it reaches all of them. The widening is confined to
199/// DELIVERY — `event_status` feeds subscription filtering only, and
200/// `WorkflowStatus` itself is projected from history elsewhere and is untouched
201/// — and it costs one extra attribute frame to status-filtered subscribers of
202/// an embedded engine that stamps no placement, which the server's own
203/// `start_search_attributes` never does. Narrowing it back would require the
204/// selector to see the whole atomic batch rather than one event, which this
205/// call site cannot; that is the reason it stands, not that nothing changed.
206fn is_start_time_stamp(
207 attributes: &std::collections::HashMap<String, aion_core::SearchAttributeValue>,
208) -> bool {
209 attributes.contains_key(NAMESPACE_ATTRIBUTE) || attributes.contains_key(TASK_QUEUE_ATTRIBUTE)
210}
211
212#[cfg(test)]
213mod tests {
214 use aion_core::{Event, EventEnvelope, Payload, WorkflowId, WorkflowStatus};
215
216 use super::SubscriptionSelector;
217
218 fn envelope(seq: u64) -> EventEnvelope {
219 EventEnvelope {
220 seq,
221 recorded_at: chrono::Utc::now(),
222 workflow_id: WorkflowId::new(uuid::Uuid::from_u128(1)),
223 }
224 }
225
226 fn payload() -> Result<Payload, aion_core::PayloadError> {
227 Payload::from_json(&serde_json::json!({ "label": "x" }))
228 }
229
230 fn signal(seq: u64) -> Result<Event, aion_core::PayloadError> {
231 Ok(Event::SignalReceived {
232 envelope: envelope(seq),
233 name: "ship".to_owned(),
234 payload: payload()?,
235 })
236 }
237
238 fn started(seq: u64) -> Result<Event, aion_core::PayloadError> {
239 Ok(Event::WorkflowStarted {
240 envelope: envelope(seq),
241 workflow_type: "checkout".to_owned(),
242 input: payload()?,
243 run_id: aion_core::RunId::new(uuid::Uuid::from_u128(1)),
244 parent_run_id: None,
245 parent_workflow_id: None,
246 package_version: aion_core::PackageVersion::new("a".repeat(64)),
247 })
248 }
249
250 fn completed(seq: u64) -> Result<Event, aion_core::PayloadError> {
251 Ok(Event::WorkflowCompleted {
252 envelope: envelope(seq),
253 result: payload()?,
254 })
255 }
256
257 fn failed(seq: u64) -> Event {
258 Event::WorkflowFailed {
259 envelope: envelope(seq),
260 error: aion_core::WorkflowError {
261 message: "boom".to_owned(),
262 details: None,
263 },
264 }
265 }
266
267 fn renamed(seq: u64) -> Event {
268 Event::SearchAttributesUpdated {
269 envelope: envelope(seq),
270 workflow_id: WorkflowId::new(uuid::Uuid::from_u128(1)),
271 attributes: std::collections::HashMap::from([(
272 aion_core::DISPLAY_NAME_ATTRIBUTE.to_owned(),
273 aion_core::SearchAttributeValue::String("Nightly settlement".to_owned()),
274 )]),
275 }
276 }
277
278 /// The start-time attribute stamp: what the engine records ATOMICALLY with
279 /// `WorkflowStarted` for every start that carries a namespace, task queue,
280 /// or display name — which, through the server, is every start.
281 fn start_stamp(seq: u64) -> Event {
282 Event::SearchAttributesUpdated {
283 envelope: envelope(seq),
284 workflow_id: WorkflowId::new(uuid::Uuid::from_u128(1)),
285 attributes: std::collections::HashMap::from([
286 (
287 crate::namespace::NAMESPACE_ATTRIBUTE.to_owned(),
288 aion_core::SearchAttributeValue::String("default".to_owned()),
289 ),
290 (
291 crate::namespace::TASK_QUEUE_ATTRIBUTE.to_owned(),
292 aion_core::SearchAttributeValue::String("settlement".to_owned()),
293 ),
294 (
295 aion_core::DISPLAY_NAME_ATTRIBUTE.to_owned(),
296 aion_core::SearchAttributeValue::String("Nightly settlement".to_owned()),
297 ),
298 ]),
299 }
300 }
301
302 /// #211, the OTHER `SearchAttributesUpdated`: the start-time stamp is NOT a
303 /// rename and must not inherit the rename's status-blindness.
304 ///
305 /// `record_workflow_started_with_attributes` appends this event in the same
306 /// atomic batch as `WorkflowStarted`, so on a server-embedded engine every
307 /// start emits one. Passing it to every status selector would hand a
308 /// `status=Completed` subscriber an attribute frame for every workflow
309 /// STARTING in the namespace — a delivery widening far past the rename this
310 /// exemption was written for, and on a busy namespace the highest-volume
311 /// event class there is.
312 ///
313 /// It genuinely accompanies a start, so it projects `Running` like the
314 /// `WorkflowStarted` it ships with.
315 #[test]
316 fn a_start_time_attribute_stamp_stays_in_the_running_bucket() {
317 let running = SubscriptionSelector {
318 workflow_type: None,
319 status: Some(WorkflowStatus::Running),
320 };
321 assert!(
322 running.matches(&start_stamp(2), Some("checkout")),
323 "the start-time stamp accompanies a start, so it belongs to Running"
324 );
325
326 for status in [
327 WorkflowStatus::Completed,
328 WorkflowStatus::Failed,
329 WorkflowStatus::Cancelled,
330 WorkflowStatus::TimedOut,
331 WorkflowStatus::ContinuedAsNew,
332 WorkflowStatus::Paused,
333 ] {
334 let selector = SubscriptionSelector {
335 workflow_type: None,
336 status: Some(status),
337 };
338 assert!(
339 !selector.matches(&start_stamp(2), Some("checkout")),
340 "a status={status:?} subscriber must not receive an attribute frame for every \
341 workflow STARTING in the namespace"
342 );
343 }
344
345 // CONTROL: the narrowing must not swallow the rename exemption it sits
346 // beside. A label-only update still reaches a terminal subscriber —
347 // without this, deleting the exemption entirely would pass the above.
348 let completed_only = SubscriptionSelector {
349 workflow_type: None,
350 status: Some(WorkflowStatus::Completed),
351 };
352 assert!(
353 completed_only.matches(&renamed(1), Some("checkout")),
354 "a label-only rename must still reach every status subscriber"
355 );
356 }
357
358 /// #211: a rename is a LABEL change, not a lifecycle event, so it reaches
359 /// EVERY status subscriber.
360 ///
361 /// Renames legally record on terminal and paused runs, which broke the old
362 /// "every non-terminal event belongs to a running workflow" premise two
363 /// ways at once: a `Running` subscriber was handed the rename of a
364 /// finished run (wrong bucket), and a `Completed` subscriber never saw
365 /// renames of the very runs it was displaying (a name that goes stale
366 /// until a refetch). Status selectors are about LIFECYCLE, and a label
367 /// change has no lifecycle meaning — so it passes the status arm whatever
368 /// the selector asks for.
369 #[test]
370 fn a_rename_reaches_every_status_subscriber() -> Result<(), Box<dyn std::error::Error>> {
371 for status in [
372 WorkflowStatus::Running,
373 WorkflowStatus::Completed,
374 WorkflowStatus::Failed,
375 WorkflowStatus::Cancelled,
376 WorkflowStatus::TimedOut,
377 WorkflowStatus::ContinuedAsNew,
378 WorkflowStatus::Paused,
379 ] {
380 let selector = SubscriptionSelector {
381 workflow_type: None,
382 status: Some(status),
383 };
384 assert!(
385 selector.matches(&renamed(1), Some("checkout")),
386 "a rename must reach a status={status:?} subscriber"
387 );
388 }
389
390 // CONTROL: bypassing the status arm must not bypass the TYPE arm, and
391 // must not make every other event status-blind either — without these
392 // the assertions above would also pass if `matches` had started
393 // returning `true` unconditionally.
394 let typed = SubscriptionSelector {
395 workflow_type: Some("checkout".to_owned()),
396 status: Some(WorkflowStatus::Completed),
397 };
398 assert!(!typed.matches(&renamed(1), Some("payments")));
399 assert!(!typed.matches(&signal(2)?, Some("checkout")));
400 Ok(())
401 }
402
403 #[test]
404 fn unrestricted_selector_matches_everything() -> Result<(), Box<dyn std::error::Error>> {
405 let selector = SubscriptionSelector::unrestricted();
406
407 assert!(selector.matches(&signal(1)?, None));
408 assert!(selector.matches(&completed(2)?, Some("checkout")));
409 Ok(())
410 }
411
412 #[test]
413 fn type_selector_matches_only_the_recorded_type() -> Result<(), Box<dyn std::error::Error>> {
414 let selector = SubscriptionSelector {
415 workflow_type: Some("checkout".to_owned()),
416 status: None,
417 };
418
419 assert!(selector.matches(&signal(1)?, Some("checkout")));
420 assert!(!selector.matches(&signal(1)?, Some("fulfillment")));
421 assert!(
422 !selector.matches(&signal(1)?, None),
423 "a workflow with no recorded type never matches a type selector"
424 );
425 Ok(())
426 }
427
428 #[test]
429 fn status_selector_matches_per_event_kind() -> Result<(), Box<dyn std::error::Error>> {
430 let running = SubscriptionSelector {
431 workflow_type: None,
432 status: Some(WorkflowStatus::Running),
433 };
434 let completed_only = SubscriptionSelector {
435 workflow_type: None,
436 status: Some(WorkflowStatus::Completed),
437 };
438 let failed_only = SubscriptionSelector {
439 workflow_type: None,
440 status: Some(WorkflowStatus::Failed),
441 };
442
443 // Running matches every non-terminal event, including WorkflowStarted.
444 assert!(running.matches(&started(1)?, Some("checkout")));
445 assert!(running.matches(&signal(2)?, Some("checkout")));
446 assert!(!running.matches(&completed(3)?, Some("checkout")));
447
448 // Each terminal status matches exactly its terminal event kind.
449 assert!(completed_only.matches(&completed(3)?, Some("checkout")));
450 assert!(!completed_only.matches(&failed(3), Some("checkout")));
451 assert!(!completed_only.matches(&signal(2)?, Some("checkout")));
452 assert!(failed_only.matches(&failed(3), Some("checkout")));
453 assert!(!failed_only.matches(&completed(3)?, Some("checkout")));
454 Ok(())
455 }
456
457 fn timed_out(seq: u64) -> Event {
458 Event::WorkflowTimedOut {
459 envelope: envelope(seq),
460 timeout: "workflow".to_owned(),
461 }
462 }
463
464 #[test]
465 fn status_selector_projects_workflow_timed_out_to_timed_out()
466 -> Result<(), Box<dyn std::error::Error>> {
467 // The ops-console stream selector must surface a `WorkflowTimedOut`
468 // terminal as `TimedOut`: a `TimedOut` subscription admits it, a
469 // `Running` subscription rejects it (it is terminal), and a `Failed`
470 // subscription does not confuse it for a failure.
471 let timed_out_only = SubscriptionSelector {
472 workflow_type: None,
473 status: Some(WorkflowStatus::TimedOut),
474 };
475 let running = SubscriptionSelector {
476 workflow_type: None,
477 status: Some(WorkflowStatus::Running),
478 };
479 let failed_only = SubscriptionSelector {
480 workflow_type: None,
481 status: Some(WorkflowStatus::Failed),
482 };
483
484 assert!(timed_out_only.matches(&timed_out(4), Some("checkout")));
485 assert!(!timed_out_only.matches(&completed(3)?, Some("checkout")));
486 assert!(!running.matches(&timed_out(4), Some("checkout")));
487 assert!(!failed_only.matches(&timed_out(4), Some("checkout")));
488 Ok(())
489 }
490
491 #[test]
492 fn combined_selectors_and_together() -> Result<(), Box<dyn std::error::Error>> {
493 let selector = SubscriptionSelector {
494 workflow_type: Some("checkout".to_owned()),
495 status: Some(WorkflowStatus::Completed),
496 };
497
498 assert!(selector.matches(&completed(3)?, Some("checkout")));
499 assert!(
500 !selector.matches(&completed(3)?, Some("fulfillment")),
501 "matching status with mismatched type must not pass"
502 );
503 assert!(
504 !selector.matches(&signal(2)?, Some("checkout")),
505 "matching type with mismatched status must not pass"
506 );
507 Ok(())
508 }
509}