1use 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
17pub struct ConcreteQueryService {
25 mailbox_engine: Arc<dyn EngineHandle>,
26 query_timeout: Duration,
27}
28
29impl ConcreteQueryService {
30 #[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 return Err(QueryError::NotRunning(target.workflow_id().clone()).into());
48 }
49 {
50 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 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 #[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}