Skip to main content

haste_operation_executor/providers/deno_embedded/
pool.rs

1use 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
24/// How many never-yet-used isolates each worker keeps ready to hand out
25/// immediately, instead of building one synchronously on the request path.
26///
27/// Jobs run strictly one at a time per worker, so a spare of 1 is enough to
28/// fully hide isolate-creation latency: each job consumes the spare left
29/// over from the previous job, then -- *after* its own response has already
30/// been sent -- builds a fresh replacement for whichever job comes next.
31/// This does not change how many isolates a call ever gets (still exactly
32/// one, used once); it only moves *when* that isolate's construction cost
33/// is paid, off the critical path of the request it doesn't belong to.
34const WARM_RUNTIME_BUFFER_SIZE: usize = 1;
35
36/// Hard ceiling on the size of a single custom operation's source code.
37const MAX_CUSTOM_OPERATION_CODE_BYTES: usize = 256 * 1024;
38
39/// How many jobs may sit queued (in addition to however many are already
40/// running) before a `DenoPool` starts rejecting new work instead of
41/// accepting it.
42const 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    /// Creates a new [`DenoPool`] with the specified number of worker threads.
52    ///
53    /// Each worker is spawned during construction. If any worker fails to start, all workers
54    /// that were successfully spawned up to that point are shut down before the error is
55    /// returned.
56    ///
57    /// All workers pull jobs from a single shared queue, so an idle worker always picks up
58    /// the next job regardless of which worker happens to be busy -- unlike a fixed
59    /// round-robin assignment, a single slow or wedged script can't head-of-line-block jobs
60    /// that land on "its" worker while other workers sit idle.
61    ///
62    /// # Arguments
63    ///
64    /// * `thread_count` - The number of worker threads to create. Must be greater than zero.
65    ///
66    /// # Errors
67    ///
68    /// Returns an error if:
69    ///
70    /// * `thread_count` is zero.
71    /// * A worker thread fails to spawn.
72    ///
73    /// If worker creation fails partway through initialization, all previously spawned workers
74    /// are shut down before the error is returned.
75    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                // Refill *after* the response has already been sent, so this
143                // build only delays the worker picking up its next job it
144                // never adds to the latency of the job that just finished.
145                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            // Local to this OS thread: `deno_core::JsRuntime` wraps a V8
327            // isolate that is not `Send`, so a spare can never be built on
328            // one thread and handed to another -- each worker must warm its
329            // own buffer.
330            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    /// Runs several jobs back-to-back on a single-worker pool -- forcing
367    /// every job after the first to consume a runtime the *previous* job's
368    /// tail-end pre-warmed -- and checks each one still gets the correct,
369    /// independent result. This is the property that actually matters here:
370    /// pre-warming must never let one call observe another's state.
371    #[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}