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