laminar_db/process_function/remote/
client.rs1use std::collections::BTreeSet;
2use std::num::NonZeroU32;
3use std::sync::Arc;
4use std::time::{Duration, SystemTime, UNIX_EPOCH};
5
6use laminar_core::state::PartitionKeyCodecV1;
7use prost::Message;
8use sha2::{Digest, Sha256};
9use tokio::sync::Semaphore;
10use tokio_util::sync::CancellationToken;
11use tonic::transport::{Channel, Endpoint};
12use tonic::Request;
13
14use super::codec::encode_activation;
15use super::response::read_response;
16use super::wire::{self, host_frame, worker_frame};
17use super::{
18 RemoteInvocationScope, MAX_FRAME_BYTES, MAX_INVOCATION_WIRE_BYTES, MAX_IN_FLIGHT,
19 PROTOCOL_VERSION,
20};
21use crate::error::DbError;
22use crate::process_function::{
23 ProcessActivation, ProcessActivationResult, ProcessFunctionDescriptor, ProcessRuntime,
24};
25
26#[derive(Clone)]
29pub struct RemoteProcessClient {
30 channel: Channel,
31 descriptor: Arc<ProcessFunctionDescriptor>,
32 digest: [u8; 32],
33 credits: Arc<Semaphore>,
34 max_in_flight: usize,
35 timeout: Duration,
36 python_replay_lifetime: Option<CancellationToken>,
37}
38
39impl RemoteProcessClient {
40 pub(super) fn bind_python_replay_lifetime(&mut self, exited: CancellationToken) {
41 self.python_replay_lifetime = Some(exited);
42 }
43
44 pub(crate) fn has_python_replay_binding(&self) -> bool {
45 self.python_replay_lifetime
46 .as_ref()
47 .is_some_and(|exited| !exited.is_cancelled())
48 }
49
50 #[must_use]
52 pub fn descriptor(&self) -> &ProcessFunctionDescriptor {
53 &self.descriptor
54 }
55
56 #[must_use]
58 pub const fn max_in_flight(&self) -> usize {
59 self.max_in_flight
60 }
61
62 pub async fn connect_loopback(
67 endpoint: &str,
68 descriptor: ProcessFunctionDescriptor,
69 max_in_flight: usize,
70 timeout: Duration,
71 ) -> Result<Self, DbError> {
72 let address = endpoint
73 .strip_prefix("http://")
74 .and_then(|value| value.parse::<std::net::SocketAddr>().ok())
75 .filter(|address| address.ip().is_loopback())
76 .ok_or_else(|| {
77 DbError::Unsupported("process worker plaintext endpoint must be loopback".into())
78 })?;
79 if max_in_flight == 0
80 || max_in_flight > MAX_IN_FLIGHT
81 || timeout < Duration::from_millis(1)
82 || timeout > Duration::from_secs(30)
83 || descriptor.runtime == ProcessRuntime::NativeRust
84 {
85 return Err(DbError::InvalidOperation(
86 "remote process client requires a remote runtime, deadline, and 1..=32 credits"
87 .into(),
88 ));
89 }
90 let manifest = descriptor.to_manifest_json()?;
91 let digest: [u8; 32] = Sha256::digest(manifest).into();
92 let uri = format!("http://{address}");
93 let channel = Endpoint::from_shared(uri)
94 .map_err(|error| DbError::Config(format!("process worker endpoint: {error}")))?
95 .connect_timeout(timeout)
96 .connect()
97 .await
98 .map_err(|error| DbError::Pipeline(format!("connect process worker: {error}")))?;
99 Ok(Self {
100 channel,
101 descriptor: Arc::new(descriptor),
102 digest,
103 credits: Arc::new(Semaphore::new(max_in_flight)),
104 max_in_flight,
105 timeout,
106 python_replay_lifetime: None,
107 })
108 }
109
110 pub async fn invoke(
116 &self,
117 scope: &RemoteInvocationScope,
118 activations: &[ProcessActivation],
119 ) -> Result<Vec<ProcessActivationResult>, DbError> {
120 if self
121 .python_replay_lifetime
122 .as_ref()
123 .is_some_and(CancellationToken::is_cancelled)
124 {
125 return Err(DbError::Pipeline(
126 "replay-bound Python worker has exited".into(),
127 ));
128 }
129 let _permit = self.credits.try_acquire().map_err(|_| {
131 DbError::BackpressureFail("process worker invocation slots full".into())
132 })?;
133 let request = self.encode_request(scope, activations)?;
134 tokio::time::timeout(self.timeout, self.exchange(scope, activations, request))
135 .await
136 .map_err(|_| DbError::Pipeline("process worker invocation timed out".into()))?
137 }
138
139 fn encode_request(
140 &self,
141 scope: &RemoteInvocationScope,
142 activations: &[ProcessActivation],
143 ) -> Result<Vec<wire::HostFrame>, DbError> {
144 let vnode_count = NonZeroU32::new(scope.vnode_count)
145 .filter(|count| scope.vnode < count.get())
146 .ok_or_else(|| DbError::InvalidOperation("invalid process invocation vnode".into()))?;
147 if scope.operator_id.is_empty()
148 || scope.operator_id.len() > 128
149 || scope.batch_id == scope.attempt_id
150 || activations.is_empty()
151 || activations.len() > self.descriptor.limits.max_batch_rows
152 {
153 return Err(DbError::InvalidOperation(
154 "invalid process invocation scope or size".into(),
155 ));
156 }
157 let deadline_unix_ms = SystemTime::now()
158 .duration_since(UNIX_EPOCH)
159 .map_err(|_| DbError::Pipeline("system clock precedes Unix epoch".into()))?
160 .as_millis()
161 .checked_add(self.timeout.as_millis())
162 .and_then(|value| u64::try_from(value).ok())
163 .ok_or_else(|| DbError::Pipeline("process invocation deadline overflow".into()))?;
164 let mut frames = vec![wire::HostFrame {
165 kind: Some(host_frame::Kind::Open(wire::Open {
166 protocol_version: PROTOCOL_VERSION,
167 descriptor_sha256: self.digest.to_vec(),
168 operator_id: scope.operator_id.clone(),
169 vnode: scope.vnode,
170 owner_generation: scope.owner_generation,
171 recovery_generation: scope.recovery_generation,
172 batch_id: scope.batch_id.as_bytes().to_vec(),
173 attempt_id: scope.attempt_id.as_bytes().to_vec(),
174 input_watermark_us: scope.input_watermark_us,
175 deadline_unix_ms,
176 vnode_count: scope.vnode_count,
177 })),
178 }];
179 let mut ids = BTreeSet::new();
180 let mut keys = BTreeSet::new();
181 let mut input_bytes = 0usize;
182 for activation in activations {
183 if !ids.insert(activation.id)
184 || !keys.insert(activation.key.as_ref())
185 || PartitionKeyCodecV1::vnode_for_encoded(&activation.key, vnode_count)
186 != scope.vnode
187 {
188 return Err(DbError::InvalidOperation(
189 "process invocation has another vnode, repeated key, or repeated ID".into(),
190 ));
191 }
192 let encoded = encode_activation(activation, &self.descriptor)?;
193 if let Some(wire::activation::Callback::InputIpc(ipc)) = &encoded.callback {
194 input_bytes = input_bytes
197 .checked_add(ipc.len())
198 .filter(|bytes| *bytes <= self.descriptor.limits.max_input_bytes)
199 .ok_or_else(|| {
200 DbError::BackpressureFail("process input IPC byte limit exceeded".into())
201 })?;
202 }
203 frames.push(wire::HostFrame {
204 kind: Some(host_frame::Kind::Activation(encoded)),
205 });
206 }
207 frames.push(wire::HostFrame {
208 kind: Some(host_frame::Kind::End(wire::End {
209 count: u32::try_from(activations.len()).map_err(|_| {
210 DbError::BackpressureFail("process batch count overflow".into())
211 })?,
212 })),
213 });
214 frames.iter().try_fold(0usize, |sum, frame| {
215 sum.checked_add(frame.encoded_len())
216 .filter(|bytes| *bytes <= MAX_INVOCATION_WIRE_BYTES)
217 .ok_or_else(|| {
218 DbError::BackpressureFail("process request wire budget exceeded".into())
219 })
220 })?;
221 Ok(frames)
222 }
223
224 async fn exchange(
225 &self,
226 scope: &RemoteInvocationScope,
227 activations: &[ProcessActivation],
228 frames: Vec<wire::HostFrame>,
229 ) -> Result<Vec<ProcessActivationResult>, DbError> {
230 let mut client =
231 wire::process_worker_client::ProcessWorkerClient::new(self.channel.clone())
232 .max_decoding_message_size(MAX_FRAME_BYTES + 64 * 1024)
233 .max_encoding_message_size(MAX_FRAME_BYTES + 64 * 1024);
234 let mut request = Request::new(tokio_stream::iter(frames));
235 request.set_timeout(self.timeout);
236 let mut inbound = client
237 .exchange(request)
238 .await
239 .map_err(|error| DbError::Pipeline(format!("process worker exchange: {error}")))?
240 .into_inner();
241 let ack = inbound
242 .message()
243 .await
244 .map_err(|error| DbError::Pipeline(format!("process worker response: {error}")))?
245 .ok_or_else(|| DbError::Pipeline("process worker closed without response".into()))?;
246 let ack_bytes = ack.encoded_len();
247 match ack.kind {
248 Some(worker_frame::Kind::Ack(ack))
249 if ack.protocol_version == PROTOCOL_VERSION
250 && ack.descriptor_sha256 == self.digest
251 && ack.attempt_id == scope.attempt_id.as_bytes() => {}
252 Some(worker_frame::Kind::Failure(failure)) => {
253 return Err(DbError::Pipeline(format!(
254 "process worker failed: {}",
255 failure.message
256 )));
257 }
258 _ => {
259 return Err(DbError::InvalidOperation(
260 "invalid process worker acknowledgement".into(),
261 ))
262 }
263 }
264 read_response(&self.descriptor, activations, ack_bytes, &mut inbound).await
265 }
266}