Skip to main content

aion/query/
concrete.rs

1//! Concrete delegated query service: residency and terminal guards, then
2//! non-recording mailbox dispatch through the query mailbox engine.
3
4use std::sync::Arc;
5use std::time::Duration;
6
7use aion_core::Payload;
8use async_trait::async_trait;
9
10use crate::engine::delegated;
11use crate::engine_seam::{EngineHandle, WorkflowProcessHandle};
12use crate::registry::HandleResidency;
13use crate::{EngineError, WorkflowHandle};
14
15use super::service::{QueryError, QueryService};
16
17/// Delegated query service for resident workflow processes.
18///
19/// The engine resolves the live `(workflow, run)` handle; this service
20/// rejects suspended residency (AT-007: never resume a workflow solely to
21/// answer a query) and terminal runs, then dispatches run-exact through the
22/// AT [`QueryService`] over the engine's query mailbox seam. Nothing on this
23/// path records events.
24pub struct ConcreteQueryService {
25    mailbox_engine: Arc<dyn EngineHandle>,
26    query_timeout: Duration,
27}
28
29impl ConcreteQueryService {
30    /// Create a query service over the engine's query mailbox seam with the
31    /// engine-configured reply timeout.
32    #[must_use]
33    pub fn new(mailbox_engine: Arc<dyn EngineHandle>, query_timeout: Duration) -> Self {
34        Self {
35            mailbox_engine,
36            query_timeout,
37        }
38    }
39}
40
41#[async_trait]
42impl delegated::QueryService for ConcreteQueryService {
43    async fn query(
44        &self,
45        target: &WorkflowHandle,
46        name: String,
47        arguments: Payload,
48    ) -> Result<Payload, EngineError> {
49        if target.residency() == HandleResidency::Suspended {
50            // A suspended workflow has no live heap to answer from, and
51            // resuming solely to answer is forbidden.
52            return Err(QueryError::NotRunning(target.workflow_id().clone()).into());
53        }
54        {
55            // Terminal check under the recorder lock: the exit monitor
56            // records terminal events through this same recorder, so a run
57            // observed non-terminal here was non-terminal when the check
58            // ran — the remaining completion race surfaces as a typed
59            // ReplyDropped, never a hang.
60            let recorder = target.recorder();
61            let recorder = recorder.lock().await;
62            let history = recorder.read_history().await.map_err(EngineError::from)?;
63            if crate::engine::delegated::run_has_terminal_history(&history, target.run_id()) {
64                return Err(QueryError::NotRunning(target.workflow_id().clone()).into());
65            }
66        }
67        let service = QueryService::new(Arc::clone(&self.mailbox_engine), self.query_timeout);
68        service
69            .query_process(WorkflowProcessHandle::new(target.pid()), name, arguments)
70            .await
71            .map_err(EngineError::Query)
72    }
73}
74
75#[cfg(test)]
76mod tests {
77    use std::collections::HashMap;
78    use std::sync::{Arc, Mutex, MutexGuard};
79    use std::time::Duration;
80
81    use aion_core::{ContentType, Event, Payload, TimerId, WorkflowId, WorkflowStatus};
82    use aion_package::ContentHash;
83    use aion_store::{EventStore, InMemoryStore};
84
85    use super::ConcreteQueryService;
86    use crate::EngineError;
87    use crate::Pid;
88    use crate::durability::Recorder;
89    use crate::engine::delegated::QueryService as _;
90    use crate::engine_seam::{
91        ChildWorkflowSpawnRequest, ChildWorkflowSpawnResult, EngineHandle, EngineSeamError,
92        TimerWheelEntry, WorkflowMailboxMessage, WorkflowProcessHandle, WorkflowResidency,
93    };
94    use crate::query::QueryError;
95    use crate::registry::{
96        CompletionNotifier, HandleResidency, WorkflowHandle, WorkflowHandleParts,
97    };
98
99    type TestResult = Result<(), Box<dyn std::error::Error>>;
100
101    const QUERY_TIMEOUT: Duration = Duration::from_millis(50);
102
103    /// One recorded mailbox delivery: target pid, query name, and the
104    /// caller's arguments payload exactly as the seam handed them over.
105    type Delivery = (u64, String, Payload);
106
107    /// Replying fake over the mailbox seam, recording every delivery's
108    /// process, query name, and arguments payload.
109    #[derive(Default)]
110    struct ReplyingMailbox {
111        replies: Mutex<HashMap<String, Payload>>,
112        delivered: Mutex<Vec<Delivery>>,
113    }
114
115    impl ReplyingMailbox {
116        fn with_reply(name: &str, payload: Payload) -> Self {
117            let fake = Self::default();
118            match fake.replies.lock() {
119                Ok(mut replies) => {
120                    replies.insert(name.to_owned(), payload);
121                }
122                Err(_) => unreachable!("fresh mutex cannot be poisoned"),
123            }
124            fake
125        }
126
127        fn delivered(&self) -> Result<Vec<Delivery>, EngineSeamError> {
128            Ok(self.lock_delivered()?.clone())
129        }
130
131        fn lock_delivered(&self) -> Result<MutexGuard<'_, Vec<Delivery>>, EngineSeamError> {
132            self.delivered
133                .lock()
134                .map_err(|_| EngineSeamError::Delivery {
135                    reason: "fake delivered lock was poisoned".to_owned(),
136                })
137        }
138    }
139
140    impl EngineHandle for ReplyingMailbox {
141        fn resolve_workflow(
142            &self,
143            _workflow_id: &WorkflowId,
144        ) -> Result<WorkflowResidency, EngineSeamError> {
145            Err(EngineSeamError::Delivery {
146                reason: "ConcreteQueryService must dispatch run-exact, never resolve".to_owned(),
147            })
148        }
149
150        fn deliver_workflow_message(
151            &self,
152            process: WorkflowProcessHandle,
153            message: WorkflowMailboxMessage,
154        ) -> Result<(), EngineSeamError> {
155            let WorkflowMailboxMessage::Query {
156                name,
157                payload,
158                reply_to,
159            } = message
160            else {
161                return Err(EngineSeamError::Delivery {
162                    reason: "fake mailbox only accepts query messages".to_owned(),
163                });
164            };
165            self.lock_delivered()?
166                .push((process.pid(), name.clone(), payload));
167            let reply = self
168                .replies
169                .lock()
170                .map_err(|_| EngineSeamError::Delivery {
171                    reason: "fake replies lock was poisoned".to_owned(),
172                })?
173                .get(&name)
174                .cloned();
175            let result = reply.ok_or(QueryError::UnknownQuery(name));
176            reply_to
177                .send(result)
178                .map_err(|_| EngineSeamError::Delivery {
179                    reason: "query caller dropped reply receiver".to_owned(),
180                })
181        }
182
183        fn spawn_child_workflow(
184            &self,
185            request: ChildWorkflowSpawnRequest,
186        ) -> Result<ChildWorkflowSpawnResult, EngineSeamError> {
187            Err(EngineSeamError::ChildSpawn {
188                reason: request.workflow_type,
189            })
190        }
191
192        fn terminate_linked_child_workflow(
193            &self,
194            parent_workflow_id: &WorkflowId,
195            child_process: WorkflowProcessHandle,
196            correlation: u64,
197        ) -> Result<(), EngineSeamError> {
198            Err(EngineSeamError::ChildTermination {
199                reason: format!("{parent_workflow_id}:{child_process:?}:{correlation}"),
200            })
201        }
202
203        fn terminate_linked_activity(
204            &self,
205            parent_workflow_id: &WorkflowId,
206            activity_process: Pid,
207            correlation: u64,
208        ) -> Result<(), EngineSeamError> {
209            Err(EngineSeamError::ChildTermination {
210                reason: format!("{parent_workflow_id}:{activity_process}:{correlation}"),
211            })
212        }
213
214        fn arm_timer(&self, entry: TimerWheelEntry) -> Result<(), EngineSeamError> {
215            Err(EngineSeamError::TimerWheel {
216                reason: entry.timer_id.to_string(),
217            })
218        }
219
220        fn disarm_timer(
221            &self,
222            process: WorkflowProcessHandle,
223            timer_id: &TimerId,
224        ) -> Result<(), EngineSeamError> {
225            Err(EngineSeamError::TimerWheel {
226                reason: format!("{process:?}:{timer_id}"),
227            })
228        }
229
230        fn record_workflow_event(
231            &self,
232            workflow_id: &WorkflowId,
233            event: Event,
234        ) -> Result<crate::engine_seam::RecordOutcome, EngineSeamError> {
235            Err(EngineSeamError::Recorder {
236                reason: format!(
237                    "queries must not record event {} for {workflow_id}",
238                    event.seq()
239                ),
240            })
241        }
242
243        fn record_redelivered_timer_fire(
244            &self,
245            workflow_id: &WorkflowId,
246            timer_id: &aion_core::TimerId,
247        ) -> Result<crate::engine_seam::RedeliveredFire, EngineSeamError> {
248            Err(EngineSeamError::Recorder {
249                reason: format!(
250                    "queries must not answer timer redelivery of `{timer_id}` for {workflow_id}"
251                ),
252            })
253        }
254    }
255
256    fn payload(label: &str) -> Result<Payload, aion_core::PayloadError> {
257        Payload::from_json(&serde_json::json!({ "label": label }))
258    }
259
260    async fn started_handle(
261        store: &Arc<dyn EventStore>,
262        pid: u64,
263        residency: HandleResidency,
264    ) -> Result<WorkflowHandle, Box<dyn std::error::Error>> {
265        let workflow_id = WorkflowId::new_v4();
266        let run_id = aion_core::RunId::new_v4();
267        let mut recorder = Recorder::new(workflow_id.clone(), Arc::clone(store));
268        recorder
269            .record_workflow_started(
270                chrono::Utc::now(),
271                crate::durability::WorkflowStartRecord {
272                    workflow_type: "checkout".to_owned(),
273                    input: payload("input")?,
274                    run_id: run_id.clone(),
275                    parent_run_id: None,
276                    parent_workflow_id: None,
277                    package_version: aion_core::PackageVersion::new("a".repeat(64)),
278                },
279            )
280            .await?;
281        Ok(WorkflowHandle::new(WorkflowHandleParts {
282            workflow_id,
283            run_id,
284            pid,
285            workflow_type: "checkout".to_owned(),
286            namespace: String::from("default"),
287            loaded_version: ContentHash::from_bytes([5; 32]),
288            cached_status: WorkflowStatus::Running,
289            residency,
290            recorder,
291            completion: CompletionNotifier::new(),
292        }))
293    }
294
295    fn assert_not_running(
296        result: Result<Payload, EngineError>,
297        handle: &WorkflowHandle,
298    ) -> Result<(), String> {
299        match result {
300            Err(EngineError::Query(QueryError::NotRunning(workflow_id)))
301                if &workflow_id == handle.workflow_id() =>
302            {
303                Ok(())
304            }
305            other => Err(format!(
306                "expected NotRunning for {}, got {other:?}",
307                handle.workflow_id()
308            )),
309        }
310    }
311
312    #[tokio::test]
313    async fn happy_path_dispatches_run_exact_and_returns_handler_reply() -> TestResult {
314        let store: Arc<dyn EventStore> = Arc::new(InMemoryStore::default());
315        let handle = started_handle(&store, 31, HandleResidency::Resident).await?;
316        let reply = Payload::new(ContentType::Json, b"{\"n\":1}".to_vec());
317        let mailbox = Arc::new(ReplyingMailbox::with_reply("state", reply.clone()));
318        let service = ConcreteQueryService::new(Arc::clone(&mailbox) as _, QUERY_TIMEOUT);
319
320        let arguments = payload("caller-arguments")?;
321        let returned = service
322            .query(&handle, "state".to_owned(), arguments.clone())
323            .await?;
324
325        assert_eq!(returned, reply);
326        // The caller's arguments reach the mailbox unmodified: this service
327        // is a pass-through, never a place that substitutes a payload.
328        assert_eq!(
329            mailbox.delivered()?,
330            vec![(31, "state".to_owned(), arguments)]
331        );
332        Ok(())
333    }
334
335    #[tokio::test]
336    async fn suspended_residency_is_not_running_and_never_delivers() -> TestResult {
337        let store: Arc<dyn EventStore> = Arc::new(InMemoryStore::default());
338        let handle = started_handle(&store, 32, HandleResidency::Suspended).await?;
339        let mailbox = Arc::new(ReplyingMailbox::with_reply("state", payload("never-used")?));
340        let service = ConcreteQueryService::new(Arc::clone(&mailbox) as _, QUERY_TIMEOUT);
341
342        let result = service
343            .query(&handle, "state".to_owned(), payload("arguments")?)
344            .await;
345
346        assert_not_running(result, &handle)?;
347        assert!(
348            mailbox.delivered()?.is_empty(),
349            "a suspended workflow must never be resumed or disturbed to answer a query"
350        );
351        Ok(())
352    }
353
354    #[tokio::test]
355    async fn terminal_history_is_not_running_and_never_delivers() -> TestResult {
356        let store: Arc<dyn EventStore> = Arc::new(InMemoryStore::default());
357        let handle = started_handle(&store, 33, HandleResidency::Resident).await?;
358        {
359            let recorder = handle.recorder();
360            let mut recorder = recorder.lock().await;
361            recorder
362                .record_workflow_completed(chrono::Utc::now(), payload("done")?)
363                .await?;
364        }
365        let mailbox = Arc::new(ReplyingMailbox::with_reply("state", payload("never-used")?));
366        let service = ConcreteQueryService::new(Arc::clone(&mailbox) as _, QUERY_TIMEOUT);
367
368        let result = service
369            .query(&handle, "state".to_owned(), payload("arguments")?)
370            .await;
371
372        assert_not_running(result, &handle)?;
373        assert!(mailbox.delivered()?.is_empty());
374        Ok(())
375    }
376
377    #[tokio::test]
378    async fn unknown_query_propagates_typed_through_engine_error() -> TestResult {
379        let store: Arc<dyn EventStore> = Arc::new(InMemoryStore::default());
380        let handle = started_handle(&store, 34, HandleResidency::Resident).await?;
381        let mailbox = Arc::new(ReplyingMailbox::default());
382        let service = ConcreteQueryService::new(Arc::clone(&mailbox) as _, QUERY_TIMEOUT);
383
384        let result = service
385            .query(&handle, "missing".to_owned(), payload("arguments")?)
386            .await;
387
388        match result {
389            Err(EngineError::Query(QueryError::UnknownQuery(name))) => {
390                assert_eq!(name, "missing");
391                Ok(())
392            }
393            other => Err(format!("expected UnknownQuery, got {other:?}").into()),
394        }
395    }
396}