1use 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
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(
44 &self,
45 target: &WorkflowHandle,
46 name: String,
47 arguments: Payload,
48 ) -> Result<Payload, EngineError> {
49 if target.residency() == HandleResidency::Suspended {
50 return Err(QueryError::NotRunning(target.workflow_id().clone()).into());
53 }
54 {
55 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 type Delivery = (u64, String, Payload);
106
107 #[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 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}