Skip to main content

laminar_db/process_function/remote/
worker.rs

1use std::collections::BTreeSet;
2use std::num::NonZeroU32;
3use std::sync::Arc;
4use std::time::Duration;
5
6use futures::Stream;
7use laminar_core::state::PartitionKeyCodecV1;
8use prost::Message;
9use sha2::{Digest, Sha256};
10use tokio::net::TcpListener;
11use tokio::sync::Semaphore;
12use tokio_stream::wrappers::TcpListenerStream;
13use tokio_util::sync::CancellationToken;
14use tonic::{Request, Response, Status};
15
16use super::codec::{decode_activation, encode_batch, encode_mutation, encode_timer};
17use super::wire::{self, host_frame, worker_frame};
18use super::{MAX_FRAME_BYTES, MAX_INVOCATION_WIRE_BYTES, MAX_IN_FLIGHT, PROTOCOL_VERSION};
19use crate::error::DbError;
20use crate::process_function::{
21    NativeProcessFunction, ProcessActivation, ProcessActivationResult, ProcessFunctionDescriptor,
22    ProcessRuntime,
23};
24
25/// Rust reference implementation of the v1 remote worker contract. It is stateless with
26/// respect to keyed business state; all state arrives in each host activation snapshot.
27#[derive(Clone)]
28pub struct RustReferenceWorker {
29    descriptor: Arc<ProcessFunctionDescriptor>,
30    digest: [u8; 32],
31    handler: Arc<dyn NativeProcessFunction>,
32    credits: Arc<Semaphore>,
33    #[cfg(all(test, feature = "cluster"))]
34    scope_observer: Option<tokio::sync::mpsc::Sender<wire::Open>>,
35}
36
37impl RustReferenceWorker {
38    /// Build a worker for a remote Rust descriptor and a bounded number of concurrent calls.
39    ///
40    /// # Errors
41    /// Rejects incompatible descriptors and zero concurrency.
42    pub fn new(
43        descriptor: ProcessFunctionDescriptor,
44        handler: Arc<dyn NativeProcessFunction>,
45        max_in_flight: usize,
46    ) -> Result<Self, DbError> {
47        if descriptor.runtime != ProcessRuntime::RemoteRust
48            || max_in_flight == 0
49            || max_in_flight > MAX_IN_FLIGHT
50        {
51            return Err(DbError::InvalidOperation(
52                "reference worker requires remote Rust and 1..=32 concurrent calls".into(),
53            ));
54        }
55        let manifest = descriptor.to_manifest_json()?;
56        let digest: [u8; 32] = Sha256::digest(manifest).into();
57        Ok(Self {
58            descriptor: Arc::new(descriptor),
59            digest,
60            handler,
61            credits: Arc::new(Semaphore::new(max_in_flight)),
62            #[cfg(all(test, feature = "cluster"))]
63            scope_observer: None,
64        })
65    }
66
67    #[cfg(all(test, feature = "cluster"))]
68    pub(crate) fn with_scope_observer(
69        mut self,
70        observer: tokio::sync::mpsc::Sender<wire::Open>,
71    ) -> Self {
72        self.scope_observer = Some(observer);
73        self
74    }
75
76    /// Serve an already bound loopback listener until shutdown. TLS and authentication are
77    /// required before a nonlocal endpoint is admitted.
78    ///
79    /// # Errors
80    /// Returns a transport error or rejects a listener bound to a nonloopback address.
81    pub async fn serve_loopback(
82        self,
83        listener: TcpListener,
84        shutdown: CancellationToken,
85    ) -> Result<(), DbError> {
86        let address = listener
87            .local_addr()
88            .map_err(|error| DbError::Pipeline(format!("worker listener address: {error}")))?;
89        if !address.ip().is_loopback() {
90            return Err(DbError::Unsupported(
91                "process reference worker only accepts a loopback listener".into(),
92            ));
93        }
94        tonic::transport::Server::builder()
95            .add_service(
96                wire::process_worker_server::ProcessWorkerServer::new(self)
97                    .max_decoding_message_size(MAX_FRAME_BYTES + 64 * 1024)
98                    .max_encoding_message_size(MAX_FRAME_BYTES + 64 * 1024),
99            )
100            .serve_with_incoming_shutdown(TcpListenerStream::new(listener), shutdown.cancelled())
101            .await
102            .map_err(|error| DbError::Pipeline(format!("process worker server: {error}")))
103    }
104
105    async fn read_request(
106        &self,
107        mut inbound: tonic::Streaming<wire::HostFrame>,
108    ) -> Result<(Vec<u8>, Vec<ProcessActivation>), Status> {
109        let first = tokio::time::timeout(Duration::from_secs(5), inbound.message())
110            .await
111            .map_err(|_| Status::deadline_exceeded("process open frame timed out"))??
112            .ok_or_else(|| Status::invalid_argument("missing process open frame"))?;
113        let first_len = first.encoded_len();
114        let Some(host_frame::Kind::Open(open)) = first.kind else {
115            return Err(Status::invalid_argument("process open must be first"));
116        };
117        self.validate_open(&open)?;
118        #[cfg(all(test, feature = "cluster"))]
119        if let Some(observer) = &self.scope_observer {
120            observer
121                .try_send(open.clone())
122                .map_err(|_| Status::internal("test scope observer is full or closed"))?;
123        }
124        let now_ms = std::time::SystemTime::now()
125            .duration_since(std::time::UNIX_EPOCH)
126            .map_err(|_| Status::internal("system clock precedes Unix epoch"))?
127            .as_millis();
128        let remaining_ms = u128::from(open.deadline_unix_ms).saturating_sub(now_ms);
129        let remaining_ms = u64::try_from(remaining_ms)
130            .map_err(|_| Status::invalid_argument("invalid process deadline"))?;
131        let deadline = tokio::time::Instant::now() + Duration::from_millis(remaining_ms);
132        let vnode_count = NonZeroU32::new(open.vnode_count)
133            .ok_or_else(|| Status::invalid_argument("empty process vnode domain"))?;
134        let mut total_wire_bytes = first_len;
135        let mut total_input_bytes = 0usize;
136        let mut activations = Vec::new();
137        let mut ids = BTreeSet::new();
138        let mut keys = BTreeSet::new();
139        loop {
140            let frame = tokio::time::timeout_at(deadline, inbound.message())
141                .await
142                .map_err(|_| Status::deadline_exceeded("process request deadline expired"))??
143                .ok_or_else(|| {
144                    Status::invalid_argument("process invocation ended without completion")
145                })?;
146            total_wire_bytes = total_wire_bytes
147                .checked_add(frame.encoded_len())
148                .filter(|total| *total <= MAX_INVOCATION_WIRE_BYTES)
149                .ok_or_else(|| {
150                    Status::resource_exhausted("process request wire budget exceeded")
151                })?;
152            match frame.kind {
153                Some(host_frame::Kind::Activation(raw)) => {
154                    if activations.len() >= self.descriptor.limits.max_batch_rows {
155                        return Err(Status::resource_exhausted(
156                            "process batch row limit exceeded",
157                        ));
158                    }
159                    let activation = decode_activation(raw, &self.descriptor)
160                        .map_err(|error| Status::invalid_argument(error.to_string()))?;
161                    if PartitionKeyCodecV1::vnode_for_encoded(&activation.key, vnode_count)
162                        != open.vnode
163                        || !ids.insert(activation.id)
164                        || !keys.insert(activation.key.to_vec())
165                    {
166                        return Err(Status::invalid_argument(
167                            "process batch contains another vnode, repeated key, or repeated ID",
168                        ));
169                    }
170                    if let crate::process_function::ProcessCallback::Input(batch) =
171                        &activation.callback
172                    {
173                        total_input_bytes = total_input_bytes
174                            .checked_add(batch.get_array_memory_size())
175                            .filter(|total| *total <= self.descriptor.limits.max_input_bytes)
176                            .ok_or_else(|| {
177                                Status::resource_exhausted("process input byte limit exceeded")
178                            })?;
179                    }
180                    activations.push(activation);
181                }
182                Some(host_frame::Kind::End(end)) => {
183                    if end.count as usize != activations.len() || activations.is_empty() {
184                        return Err(Status::invalid_argument("process end count mismatch"));
185                    }
186                    if tokio::time::timeout_at(deadline, inbound.message())
187                        .await
188                        .map_err(|_| {
189                            Status::deadline_exceeded("process request deadline expired")
190                        })??
191                        .is_some()
192                    {
193                        return Err(Status::invalid_argument("process frame after end"));
194                    }
195                    return Ok((open.attempt_id, activations));
196                }
197                _ => return Err(Status::invalid_argument("invalid process request frame")),
198            }
199        }
200    }
201
202    fn validate_open(&self, open: &wire::Open) -> Result<(), Status> {
203        if open.protocol_version != PROTOCOL_VERSION
204            || open.descriptor_sha256 != self.digest
205            || open.operator_id.is_empty()
206            || open.operator_id.len() > 128
207            || open.vnode_count == 0
208            || open.vnode >= open.vnode_count
209            || open.batch_id.len() != 16
210            || open.attempt_id.len() != 16
211            || open.batch_id == open.attempt_id
212            || open.deadline_unix_ms == 0
213        {
214            return Err(Status::failed_precondition(
215                "process protocol, descriptor, or invocation scope mismatch",
216            ));
217        }
218        let now_ms = std::time::SystemTime::now()
219            .duration_since(std::time::UNIX_EPOCH)
220            .map_err(|_| Status::internal("system clock precedes Unix epoch"))?
221            .as_millis();
222        if now_ms >= u128::from(open.deadline_unix_ms)
223            || u128::from(open.deadline_unix_ms) - now_ms > 30_000
224        {
225            return Err(Status::deadline_exceeded(
226                "process invocation deadline expired or exceeds 30 seconds",
227            ));
228        }
229        Ok(())
230    }
231
232    fn encode_response(
233        &self,
234        attempt_id: Vec<u8>,
235        activation_ids: &[u64],
236        results: Vec<ProcessActivationResult>,
237    ) -> Result<Vec<wire::WorkerFrame>, Status> {
238        if results.len() != activation_ids.len() {
239            return Err(Status::internal(
240                "reference handler returned wrong result count",
241            ));
242        }
243        let mut frames = Vec::new();
244        frames.push(wire::WorkerFrame {
245            kind: Some(worker_frame::Kind::Ack(wire::OpenAck {
246                protocol_version: PROTOCOL_VERSION,
247                descriptor_sha256: self.digest.to_vec(),
248                attempt_id,
249            })),
250        });
251        let expected = activation_ids.iter().copied().collect::<BTreeSet<_>>();
252        let mut seen = BTreeSet::new();
253        let mut output_rows = 0usize;
254        let mut output_bytes = 0usize;
255        let mut output_count = 0usize;
256        let mut timer_count = 0usize;
257        for result in results {
258            if !expected.contains(&result.activation_id) || !seen.insert(result.activation_id) {
259                return Err(Status::internal(
260                    "reference handler returned invalid activation ID",
261                ));
262            }
263            let output_len = result.output.len();
264            let output_count_u32 = u32::try_from(output_len)
265                .map_err(|_| Status::resource_exhausted("too many process output batches"))?;
266            output_count = output_count
267                .checked_add(output_len)
268                .filter(|count| *count <= self.descriptor.limits.max_output_rows.max(1))
269                .ok_or_else(|| Status::resource_exhausted("process output batch limit exceeded"))?;
270            timer_count = timer_count
271                .checked_add(result.timers.len())
272                .filter(|count| *count <= self.descriptor.limits.max_timers)
273                .ok_or_else(|| Status::resource_exhausted("process result timer limit exceeded"))?;
274            frames.push(wire::WorkerFrame {
275                kind: Some(worker_frame::Kind::Result(wire::Result {
276                    activation_id: result.activation_id,
277                    output_count: output_count_u32,
278                    mutation: Some(encode_mutation(result.value)),
279                    timers: result.timers.into_iter().map(encode_timer).collect(),
280                })),
281            });
282            for batch in result.output {
283                output_rows = output_rows
284                    .checked_add(batch.num_rows())
285                    .filter(|rows| *rows <= self.descriptor.limits.max_output_rows)
286                    .ok_or_else(|| {
287                        Status::resource_exhausted("process output row limit exceeded")
288                    })?;
289                output_bytes = output_bytes
290                    .checked_add(batch.get_array_memory_size())
291                    .filter(|bytes| *bytes <= self.descriptor.limits.max_output_bytes)
292                    .ok_or_else(|| {
293                        Status::resource_exhausted("process output byte limit exceeded")
294                    })?;
295                let ipc = encode_batch(&batch, &self.descriptor.output_schema)
296                    .map_err(|error| Status::internal(error.to_string()))?;
297                frames.push(wire::WorkerFrame {
298                    kind: Some(worker_frame::Kind::Output(wire::Output {
299                        activation_id: result.activation_id,
300                        arrow_ipc: ipc,
301                    })),
302                });
303            }
304        }
305        frames.push(wire::WorkerFrame {
306            kind: Some(worker_frame::Kind::Complete(wire::Complete {
307                result_count: u32::try_from(seen.len())
308                    .map_err(|_| Status::resource_exhausted("process result count overflow"))?,
309                output_count: u32::try_from(output_count)
310                    .map_err(|_| Status::resource_exhausted("process output count overflow"))?,
311            })),
312        });
313        frames.iter().try_fold(0usize, |sum, frame| {
314            sum.checked_add(frame.encoded_len())
315                .filter(|total| *total <= MAX_INVOCATION_WIRE_BYTES)
316                .ok_or_else(|| Status::resource_exhausted("process response wire budget exceeded"))
317        })?;
318        Ok(frames)
319    }
320}
321
322#[tonic::async_trait]
323impl wire::process_worker_server::ProcessWorker for RustReferenceWorker {
324    type ExchangeStream =
325        std::pin::Pin<Box<dyn Stream<Item = Result<wire::WorkerFrame, Status>> + Send>>;
326
327    async fn exchange(
328        &self,
329        request: Request<tonic::Streaming<wire::HostFrame>>,
330    ) -> Result<Response<Self::ExchangeStream>, Status> {
331        // INVARIANT: the permit remains with the response stream until it is sent or cancelled.
332        let permit = self
333            .credits
334            .clone()
335            .try_acquire_owned()
336            .map_err(|_| Status::resource_exhausted("process worker invocation slots full"))?;
337        let inbound = request.into_inner();
338        let (attempt_id, activations) = self.read_request(inbound).await?;
339        let handler = Arc::clone(&self.handler);
340        let activation_ids = activations.iter().map(|item| item.id).collect::<Vec<_>>();
341        // A cancelled RPC can leave its blocking handler running. Ownership of the permit
342        // moves into that task, so cancellation cannot oversubscribe the worker.
343        let (permit, results) = tokio::task::spawn_blocking(move || {
344            let results = handler.invoke(&activations);
345            (permit, results)
346        })
347        .await
348        .map_err(|error| Status::internal(format!("process handler task failed: {error}")))?;
349        let frames = match results {
350            Ok(results) => self.encode_response(attempt_id, &activation_ids, results)?,
351            Err(error) => vec![wire::WorkerFrame {
352                kind: Some(worker_frame::Kind::Failure(wire::Failure {
353                    message: error.to_string(),
354                })),
355            }],
356        };
357        let stream = tokio_stream::iter(frames.into_iter().map(move |frame| {
358            let _keep_credit = &permit;
359            Ok(frame)
360        }));
361        Ok(Response::new(Box::pin(stream)))
362    }
363}