haste_operation_executor/providers/deno_embedded/
pool.rs1use crate::providers::deno_embedded::{EXECUTION_TIMEOUT, build_deno_runtime, run_code};
2use crate::structs::PluginCodeType;
3use crate::traits::OperationExecutor;
4use crate::validate::validate_parameters;
5use crate::{CUSTOM_CODE_EXTENSION_URL, extract_code_from_operation_definition};
6use crossbeam_channel::{Receiver, Sender};
7use deno_core::serde_json::json;
8use deno_core::{error::AnyError, serde_json};
9use haste_fhir_client::FHIRClient;
10use haste_fhir_client::request::InvocationRequest;
11use haste_fhir_model::r4::generated::resources::{OperationDefinition, Parameters};
12use haste_fhir_model::r4::generated::terminology::{IssueType, OperationParameterUse};
13use haste_fhir_operation_error::OperationOutcomeError;
14use std::collections::VecDeque;
15use std::io;
16use std::sync::Arc;
17use std::sync::mpsc;
18use std::thread::{self, JoinHandle};
19use tokio::runtime::Runtime;
20use tokio::sync::oneshot;
21
22type JobResult = Result<Option<serde_json::Value>, AnyError>;
23
24const WARM_RUNTIME_BUFFER_SIZE: usize = 1;
35
36const MAX_CUSTOM_OPERATION_CODE_BYTES: usize = 256 * 1024;
38
39const QUEUE_DEPTH_MULTIPLIER: usize = 4;
43
44pub struct DenoPool {
45 command_tx: Sender<WorkerCommand>,
46 workers: Vec<JoinHandle<()>>,
47 max_queue_depth: usize,
48}
49
50impl DenoPool {
51 pub fn new(thread_count: usize) -> Result<Self, AnyError> {
76 if thread_count == 0 {
77 return Err(io::Error::other("DenoPool requires at least one worker thread").into());
78 }
79
80 let (command_tx, command_rx) = crossbeam_channel::unbounded();
81 let mut workers = Vec::with_capacity(thread_count);
82
83 for index in 0..thread_count {
84 let result = spawn_worker(index, command_rx.clone());
85
86 match result {
87 Ok(worker) => workers.push(worker),
88 Err(error) => {
89 shutdown_workers(&command_tx, &mut workers);
90 return Err(error);
91 }
92 }
93 }
94
95 Ok(Self {
96 command_tx,
97 workers,
98 max_queue_depth: thread_count * QUEUE_DEPTH_MULTIPLIER,
99 })
100 }
101
102 async fn execute<
103 CTX: Clone + Send + 'static,
104 Client: FHIRClient<CTX, OperationOutcomeError> + 'static,
105 >(
106 &self,
107 ctx: CTX,
108 client: Arc<Client>,
109 media_type: PluginCodeType,
110 code: impl Into<String>,
111 input: serde_json::Value,
112 ) -> JobResult {
113 let (response_tx, response_rx) = oneshot::channel();
114 let code = code.into();
115
116 let task = Box::new(
117 move |runtime: &Runtime, warm_pool: &mut VecDeque<deno_core::JsRuntime>| {
118 let prewarmed = warm_pool.pop_front();
119
120 let result = runtime.block_on(async move {
121 let deno_runtime = prewarmed.unwrap_or_else(build_deno_runtime::<CTX, Client>);
122
123 let output = run_code(
124 deno_runtime,
125 ctx,
126 client,
127 media_type,
128 &code,
129 input,
130 EXECUTION_TIMEOUT,
131 )
132 .await?;
133
134 output
135 .map(serde_json::from_value)
136 .transpose()
137 .map_err(AnyError::from)
138 });
139
140 let _ = response_tx.send(result);
141
142 if warm_pool.len() < WARM_RUNTIME_BUFFER_SIZE {
146 warm_pool.push_back(build_deno_runtime::<CTX, Client>());
147 }
148 },
149 ) as Box<dyn WorkerTask>;
150
151 self.command_tx
152 .send(WorkerCommand::Run(task))
153 .map_err(|_| io::Error::other("DenoPool has no workers accepting jobs"))?;
154
155 response_rx
156 .await
157 .map_err(|_| io::Error::other("DenoPool worker dropped the response channel"))?
158 }
159}
160
161impl Drop for DenoPool {
162 fn drop(&mut self) {
163 shutdown_workers(&self.command_tx, &mut self.workers);
164 }
165}
166
167fn get_parameters(input: &InvocationRequest) -> &Parameters {
168 match input {
169 InvocationRequest::Instance(instance_request) => &instance_request.parameters,
170 InvocationRequest::Type(type_request) => &type_request.parameters,
171 InvocationRequest::System(system_request) => &system_request.parameters,
172 }
173}
174
175fn request_to_json(input: &InvocationRequest) -> Result<serde_json::Value, OperationOutcomeError> {
176 let parameter_json: serde_json::Value =
177 serde_json::to_value(get_parameters(input)).map_err(|_| {
178 OperationOutcomeError::error(
179 IssueType::invalid(),
180 "Failed to convert operation input parameters to JSON value".to_string(),
181 )
182 })?;
183
184 match input {
185 InvocationRequest::Instance(instance_request) => Ok(json!({
186 "id": &instance_request.id,
187 "resource": instance_request.resource_type.as_ref(),
188 "parameters": parameter_json,
189
190 })),
191 InvocationRequest::Type(type_request) => Ok(json!({
192 "resource": type_request.resource_type.as_ref(),
193 "parameters": parameter_json,
194 })),
195 InvocationRequest::System(_system_request) => Ok(json!({
196 "parameters": parameter_json,
197 })),
198 }
199}
200
201impl OperationExecutor for DenoPool {
202 async fn execute_operation<
203 CTX: Clone + Send + 'static,
204 Client: FHIRClient<CTX, OperationOutcomeError> + 'static,
205 >(
206 &self,
207 context: CTX,
208 client: Arc<Client>,
209 operation: &OperationDefinition,
210 input: &InvocationRequest,
211 ) -> Result<Parameters, OperationOutcomeError> {
212 validate_parameters(
213 get_parameters(input),
214 operation.parameter.as_deref().unwrap_or_default(),
215 &OperationParameterUse::in_(),
216 )?;
217
218 if self.command_tx.len() >= self.max_queue_depth {
219 return Err(OperationOutcomeError::error(
220 IssueType::throttled(),
221 "Too many custom operations are already queued; try again shortly".to_string(),
222 ));
223 }
224
225 let (code, media_type) =
226 extract_code_from_operation_definition(operation).ok_or_else(|| {
227 OperationOutcomeError::error(
228 IssueType::invalid(),
229 format!(
230 "OperationDefinition missing custom code extension metadata '{CUSTOM_CODE_EXTENSION_URL}'"
231 ),
232 )
233 })?;
234
235 if code.len() > MAX_CUSTOM_OPERATION_CODE_BYTES {
236 return Err(OperationOutcomeError::error(
237 IssueType::invalid(),
238 format!(
239 "Custom operation source code exceeds the maximum allowed size of {MAX_CUSTOM_OPERATION_CODE_BYTES} bytes"
240 ),
241 ));
242 }
243
244 let media_type = PluginCodeType::try_from(media_type)?;
245
246 let output = self
247 .execute(
248 context,
249 client,
250 media_type,
251 code.to_string(),
252 request_to_json(input)?,
253 )
254 .await
255 .map_err(|error| {
256 OperationOutcomeError::error(
257 IssueType::processing(),
258 format!("Failed to execute operation custom code: {error}"),
259 )
260 })?
261 .ok_or_else(|| {
262 OperationOutcomeError::error(
263 IssueType::processing(),
264 "Operation custom code returned no output".to_string(),
265 )
266 })?;
267
268 let output = serde_json::from_value::<Parameters>(output).map_err(|error| {
269 OperationOutcomeError::error(
270 IssueType::invalid(),
271 format!("Operation custom code returned invalid Parameters payload: {error}"),
272 )
273 })?;
274
275 validate_parameters(
276 &output,
277 operation.parameter.as_deref().unwrap_or_default(),
278 &OperationParameterUse::out(),
279 )?;
280
281 Ok(output)
282 }
283}
284
285enum WorkerCommand {
286 Run(Box<dyn WorkerTask>),
287 Shutdown,
288}
289
290trait WorkerTask: Send + 'static {
291 fn run(self: Box<Self>, runtime: &Runtime, warm_pool: &mut VecDeque<deno_core::JsRuntime>);
292}
293
294impl<Function> WorkerTask for Function
295where
296 Function: FnOnce(&Runtime, &mut VecDeque<deno_core::JsRuntime>) + Send + 'static,
297{
298 fn run(self: Box<Self>, runtime: &Runtime, warm_pool: &mut VecDeque<deno_core::JsRuntime>) {
299 (*self)(runtime, warm_pool);
300 }
301}
302
303fn spawn_worker(
304 index: usize,
305 command_rx: Receiver<WorkerCommand>,
306) -> Result<JoinHandle<()>, AnyError> {
307 let (startup_tx, startup_rx) = mpsc::sync_channel(1);
308
309 let join_handle = thread::Builder::new()
310 .name(format!("deno-pool-{index}"))
311 .spawn(move || {
312 let runtime = match tokio::runtime::Builder::new_current_thread()
313 .enable_all()
314 .build()
315 {
316 Ok(runtime) => {
317 let _ = startup_tx.send(Ok(()));
318 runtime
319 }
320 Err(error) => {
321 let _ = startup_tx.send(Err::<(), AnyError>(error.into()));
322 return;
323 }
324 };
325
326 let mut warm_pool: VecDeque<deno_core::JsRuntime> =
331 VecDeque::with_capacity(WARM_RUNTIME_BUFFER_SIZE);
332
333 while let Ok(command) = command_rx.recv() {
334 match command {
335 WorkerCommand::Run(task) => task.run(&runtime, &mut warm_pool),
336 WorkerCommand::Shutdown => break,
337 }
338 }
339 })?;
340
341 startup_rx
342 .recv()
343 .map_err(|_| io::Error::other("DenoPool worker failed during startup"))??;
344
345 Ok(join_handle)
346}
347
348fn shutdown_workers(command_tx: &Sender<WorkerCommand>, workers: &mut Vec<JoinHandle<()>>) {
349 for _ in 0..workers.len() {
350 let _ = command_tx.send(WorkerCommand::Shutdown);
351 }
352
353 for join_handle in workers.drain(..) {
354 let _ = join_handle.join();
355 }
356}
357
358#[cfg(test)]
359mod tests {
360 use super::*;
361 use crate::CUSTOM_CODE_TYPE_EXTENSION_URL;
362 use crate::providers::deno_embedded::tests::{MockClient, TestCtx};
363 use haste_fhir_client::request::{FHIRInvokeSystemRequest, Operation};
364 use haste_fhir_model::r4::generated::types::{Extension, ExtensionValueTypeChoice, FHIRString};
365
366 #[tokio::test]
372 async fn sequential_jobs_on_one_worker_each_get_independent_results() {
373 let pool = DenoPool::new(1).expect("pool should start");
374
375 for i in 0..5 {
376 let result = pool
377 .execute(
378 TestCtx,
379 Arc::new(MockClient),
380 PluginCodeType::JavaScript,
381 format!("export default async function () {{ return {{ n: {i} }}; }}"),
382 json!({}),
383 )
384 .await
385 .expect("job should succeed")
386 .expect("job should return a value");
387
388 assert_eq!(result, json!({ "n": i }));
389 }
390 }
391
392 fn operation_definition_with_code(code: &str) -> OperationDefinition {
393 let type_extension = Extension {
394 url: CUSTOM_CODE_TYPE_EXTENSION_URL.to_string(),
395 value: Some(ExtensionValueTypeChoice::String(Box::new(FHIRString {
396 value: Some("javascript".to_string()),
397 ..Default::default()
398 }))),
399 ..Default::default()
400 };
401
402 let code_extension = Extension {
403 url: CUSTOM_CODE_EXTENSION_URL.to_string(),
404 value: Some(ExtensionValueTypeChoice::String(Box::new(FHIRString {
405 value: Some(code.to_string()),
406 ..Default::default()
407 }))),
408 extension: Some(vec![type_extension]),
409 ..Default::default()
410 };
411
412 OperationDefinition {
413 extension: Some(vec![code_extension]),
414 ..Default::default()
415 }
416 }
417
418 #[tokio::test]
419 async fn oversized_custom_operation_code_is_rejected() {
420 let pool = DenoPool::new(1).expect("pool should start");
421 let oversized_code = "a".repeat(MAX_CUSTOM_OPERATION_CODE_BYTES + 1);
422 let operation = operation_definition_with_code(&oversized_code);
423 let request = InvocationRequest::System(FHIRInvokeSystemRequest {
424 operation: Operation::new("test-op"),
425 parameters: Parameters::default(),
426 });
427
428 let result = pool
429 .execute_operation(TestCtx, Arc::new(MockClient), &operation, &request)
430 .await;
431
432 assert!(
433 result.is_err(),
434 "oversized custom operation code must be rejected before execution"
435 );
436 }
437}