somatize_fabric_wire/worker.rs
1//! A process that gets sent work. This side.
2//!
3//! The first real implementation of [`Transport`], and it does what the GIL
4//! will not allow with threads: two Python nodes in the same wave interleave
5//! but do not overlap, and in two processes they do.
6//!
7//! **The session opens by itself, and once.** The greeting is sent not when the
8//! process starts but **before the first job**: a worker that stands up and
9//! receives nothing should have done nothing.
10//!
11//! **A worker serves one at a time.** [`Transport`] is `Sync`, so two branches
12//! of a wave can call [`dispatch`] at once, and a pipe does not fit two
13//! conversations. The `Mutex` queues them, which is correct and not a
14//! limitation: a worker is **one** process, and two at once means two workers
15//! on two hosts.
16//!
17//! [`dispatch`]: Transport::dispatch
18//!
19//! ```ignore
20//! Worker::spawn(Command::new("./my-worker")) // a child, over pipes
21//! Worker::connect("node3:7000") // one that was already standing
22//! ```
23//!
24//! The first is convenient for testing and **does not satisfy the use case**:
25//! as long as the client starts the process, there is no independent worker
26//! worth the name. That the conversation never finds out which it is falls out
27//! of [`frame`](crate::frame) working over `impl Read`/`impl Write`.
28//!
29//! [`Worker::spawn`] takes a ready-made [`Command`] rather than a path because
30//! this library does not know what your binary is called, nor what environment
31//! it needs, nor whether it goes inside an `srun`.
32
33use crate::frame;
34use crate::{Answer, Artifact, Request};
35use somatize_core::{Cargo, Outcome, Plan, Transport, TransportError, Watcher};
36use somatize_core::{Codec, packed_all, unpacked_all};
37use std::io::{self, BufReader};
38use std::net::{TcpStream, ToSocketAddrs};
39use std::process::{Child, ChildStdin, ChildStdout, Command, Stdio};
40use std::sync::{Arc, Mutex};
41
42/// A process that executes the slices it is sent.
43pub struct Worker {
44 /// The process, its two ends and how far the conversation has got, held at
45 /// once so two threads do not cross halfway through a message.
46 open: Mutex<Open>,
47 /// What to provision it with and how this client identifies itself; `None`
48 /// is a worker that brings its own catalog. Behind a lock because it is set
49 /// **after** opening: which nodes go here is known at run time.
50 carries: Mutex<Option<(Artifact, String)>>,
51 /// Who writes down what would not otherwise cross. `None` is the whole of
52 /// Rust: there, an opaque carries something nobody has said how to write.
53 /// Owned and not lent, unlike [`Serving`](crate::Serving)'s: this type has
54 /// no lifetime and is held inside an `Arc` by whoever executes.
55 codec: Option<Arc<dyn Codec>>,
56}
57
58struct Open {
59 link: Link,
60 /// The session opens once, not once per job.
61 greeted: bool,
62}
63
64/// How the worker is spoken to. Both variants do the same thing; what changes
65/// is who started the process and who ends it.
66enum Link {
67 /// A child process, over its pipes.
68 Child {
69 child: Child,
70 /// `Option` so it can be closed on its own: that is the child's signal
71 /// that there is no more work.
72 to: Option<ChildStdin>,
73 from: BufReader<ChildStdout>,
74 },
75 /// A worker that was already standing. Two handles on the same socket, so
76 /// it can write and read without fighting over a borrow.
77 Socket {
78 to: TcpStream,
79 from: BufReader<TcpStream>,
80 },
81}
82
83impl Link {
84 fn send(&mut self, payload: &[u8]) -> io::Result<()> {
85 match self {
86 Self::Child { to, .. } => match to {
87 Some(to) => frame::send(to, payload),
88 None => Err(io::Error::new(
89 io::ErrorKind::BrokenPipe,
90 "this worker is already closed",
91 )),
92 },
93 Self::Socket { to, .. } => frame::send(to, payload),
94 }
95 }
96
97 fn recv(&mut self) -> io::Result<Option<Vec<u8>>> {
98 match self {
99 Self::Child { from, .. } => frame::recv(from),
100 Self::Socket { from, .. } => frame::recv(from),
101 }
102 }
103}
104
105impl Worker {
106 /// Starts the process and keeps its pipes. The worker there has to bring
107 /// its own catalog; for an empty one, see [`Worker::carrying`].
108 ///
109 /// `stderr` is left **inherited**: its `stdout` is the wire.
110 pub fn spawn(mut command: Command) -> io::Result<Self> {
111 let mut child = command
112 .stdin(Stdio::piped())
113 .stdout(Stdio::piped())
114 .spawn()?;
115 let to = child.stdin.take().expect("just asked for it with `piped`");
116 let from = child.stdout.take().expect("just asked for it with `piped`");
117 Ok(Self::over(Link::Child {
118 child,
119 to: Some(to),
120 from: BufReader::new(from),
121 }))
122 }
123
124 /// Connects to a worker that was already running — the form that satisfies
125 /// the use case. On the other side there is [`Serving::listen`](crate::Serving::listen).
126 pub fn connect(addr: impl ToSocketAddrs) -> io::Result<Self> {
127 let to = TcpStream::connect(addr)?;
128 // No `Nagle`: the messages are small and go ping-pong.
129 to.set_nodelay(true)?;
130 let from = BufReader::new(to.try_clone()?);
131 Ok(Self::over(Link::Socket { to, from }))
132 }
133
134 /// The same worker, carrying what to provision it with if it started empty.
135 /// `runtime` — `cpython-3.13/cloudpickle-3.1` — is what the far side's
136 /// [`Provision`](crate::Provision) can reject at greeting time.
137 pub fn carrying(self, artifact: Artifact, runtime: impl Into<String>) -> Self {
138 let _ = self.offering(artifact, runtime);
139 self
140 }
141
142 /// The same on an already-built worker, for whoever decides at run time
143 /// which nodes go to this host. Setting the same artifact twice does
144 /// nothing; changing an open session's fails.
145 pub fn offering(
146 &self,
147 artifact: Artifact,
148 runtime: impl Into<String>,
149 ) -> Result<(), TransportError> {
150 let mut carries = match self.carries.lock() {
151 Ok(carries) => carries,
152 Err(poisoned) => poisoned.into_inner(),
153 };
154 if let Some((already, _)) = carries.as_ref() {
155 if already.id == artifact.id {
156 return Ok(());
157 }
158 if self.greeted() {
159 return Err(TransportError::new(format!(
160 "this worker already opened a session with `{}` and its catalog \
161 cannot be changed to `{}` without reconnecting",
162 already.id, artifact.id
163 )));
164 }
165 }
166 *carries = Some((artifact, runtime.into()));
167 Ok(())
168 }
169
170 /// The same worker, with somebody who knows how to write down what an
171 /// opaque carries.
172 ///
173 /// Without one, a value that only exists in this process is refused at
174 /// encoding time. With one, it crosses as bytes and the refusal is left for
175 /// what nobody registered a codec for.
176 pub fn packing(mut self, codec: Arc<dyn Codec>) -> Self {
177 self.codec = Some(codec);
178 self
179 }
180
181 fn greeted(&self) -> bool {
182 match self.open.lock() {
183 Ok(open) => open.greeted,
184 Err(poisoned) => poisoned.into_inner().greeted,
185 }
186 }
187
188 fn over(link: Link) -> Self {
189 Self {
190 open: Mutex::new(Open {
191 link,
192 greeted: false,
193 }),
194 carries: Mutex::new(None),
195 codec: None,
196 }
197 }
198}
199
200impl Open {
201 /// Sends a message and waits for the one that answers it, handing whatever
202 /// the far side says on the way to `seen`.
203 ///
204 /// It reads **until an answer is terminal**, which is the one change the
205 /// live half of this needed: [`Answer::Saw`] is not an answer to anything,
206 /// it is the worker talking while it works.
207 ///
208 /// A fact is passed on exactly as it was emitted. Attributing it to a host
209 /// is the engine's job: here the host is an address, and the name the graph
210 /// gave it is not known.
211 fn say(
212 &mut self,
213 request: &Request,
214 seen: Option<&dyn Watcher>,
215 ) -> Result<Answer, TransportError> {
216 let payload = request
217 .to_bytes()
218 .map_err(|e| TransportError::new(e.to_string()))?;
219
220 self.link.send(&payload).map_err(|e| broke(&e))?;
221 loop {
222 let answer = self
223 .link
224 .recv()
225 .map_err(|e| broke(&e))?
226 .ok_or_else(|| TransportError::new("the worker closed without answering"))?;
227 match Answer::from_bytes(&answer).map_err(|e| TransportError::new(e.to_string()))? {
228 // Not an answer: keep waiting for one. A client that is not
229 // watching still has to read these off the socket — dropping
230 // them is what not watching means, and leaving them there would
231 // desynchronise the conversation.
232 Answer::Saw(fact) => {
233 if let Some(seen) = seen {
234 seen.saw(&fact);
235 }
236 }
237 terminal => return Ok(terminal),
238 }
239 }
240 }
241
242 /// Opens the session, if it was not open: the artifact's **name** is
243 /// announced and the bytes only sent if the worker asks for them.
244 fn greet(
245 &mut self,
246 runtime: &str,
247 artifact: Option<&Artifact>,
248 seen: Option<&dyn Watcher>,
249 ) -> Result<(), TransportError> {
250 if self.greeted {
251 return Ok(());
252 }
253 let hello = Request::Hello {
254 runtime: runtime.to_string(),
255 offering: artifact.map(Artifact::label),
256 };
257 match self.say(&hello, seen)? {
258 Answer::Ready => {}
259 Answer::Send => {
260 let artifact = artifact.ok_or_else(|| {
261 TransportError::new(
262 "the worker asked for an artifact and this client brings none",
263 )
264 })?;
265 let sending = Request::Provision {
266 bytes: artifact.bytes.clone(),
267 };
268 match self.say(&sending, seen)? {
269 Answer::Ready => {}
270 other => return Err(unexpected("provisioning", &other)),
271 }
272 }
273 other => return Err(unexpected("greeting", &other)),
274 }
275 self.greeted = true;
276 Ok(())
277 }
278}
279
280impl Transport for Worker {
281 fn dispatch(
282 &self,
283 plan: &Plan,
284 cargo: &Cargo<'_>,
285 seen: Option<&dyn Watcher>,
286 ) -> Result<Outcome, TransportError> {
287 let carries = self
288 .carries
289 .lock()
290 .map_err(|_| TransportError::new("this worker was poisoned by an earlier panic"))?;
291 let mut open = self
292 .open
293 .lock()
294 .map_err(|_| TransportError::new("this worker was poisoned by an earlier panic"))?;
295
296 match carries.as_ref() {
297 Some((artifact, runtime)) => open.greet(runtime, Some(artifact), seen)?,
298 None => open.greet("rust", None, seen)?,
299 }
300 drop(carries);
301
302 // Written down before the message is built, so the refusal in
303 // `Request::to_bytes` is untouched and still guards: by the time it
304 // looks, whatever had a codec is already bytes.
305 let (input, known) = match self.codec.as_deref() {
306 None => (cargo.input.clone(), cargo.known.to_vec()),
307 Some(codec) => (
308 codec.packed(cargo.input).map_err(as_transport_error)?,
309 packed_all(codec, cargo.known).map_err(as_transport_error)?,
310 ),
311 };
312 let work = Request::Work {
313 plan: plan.clone(),
314 input,
315 known,
316 keys: cargo.keys.to_vec(),
317 placement: cargo.placement.clone(),
318 memory: cargo.memory.clone(),
319 };
320 match open.say(&work, seen)? {
321 Answer::Done(outcome) => match self.codec.as_deref() {
322 None => Ok(outcome),
323 Some(codec) => live(codec, outcome),
324 },
325 Answer::Failed(why) => Err(TransportError::new(why)),
326 other => Err(unexpected("working", &other)),
327 }
328 }
329}
330
331/// What came back, alive again.
332///
333/// A failure here is not a value left behind: everything in this answer was
334/// written down by the other side a moment ago, so one that cannot be read back
335/// means the two ends do not register the same codecs — and that is the answer.
336fn live(codec: &dyn Codec, outcome: Outcome) -> Result<Outcome, TransportError> {
337 Ok(Outcome {
338 last: codec.unpacked(&outcome.last).map_err(as_transport_error)?,
339 produced: unpacked_all(codec, &outcome.produced).map_err(as_transport_error)?,
340 keys: outcome.keys,
341 })
342}
343
344fn as_transport_error(e: somatize_core::CodecError) -> TransportError {
345 TransportError::new(e.to_string())
346}
347
348/// What it answered does not match what it was asked: not a job failure, but
349/// the two sides not speaking the same protocol.
350fn unexpected(during: &str, answer: &Answer) -> TransportError {
351 match answer {
352 Answer::Refused(why) => {
353 TransportError::new(format!("the worker does not accept the session: {why}"))
354 }
355 other => TransportError::new(format!(
356 "while {during}, the worker answered something beside the point: {other:?}"
357 )),
358 }
359}
360
361/// A pipe failure almost always means the same thing — unless the error
362/// already explains itself.
363fn broke(e: &std::io::Error) -> TransportError {
364 match e.kind() {
365 std::io::ErrorKind::InvalidData | std::io::ErrorKind::InvalidInput => {
366 TransportError::new(e.to_string())
367 }
368 _ => TransportError::new(format!(
369 "the conversation with the worker was cut off ({e}); usually it has \
370 died — check its stderr"
371 )),
372 }
373}
374
375impl Drop for Worker {
376 /// Ends the conversation: a child gets its input closed and is waited for;
377 /// a standing worker just loses the socket and awaits another client.
378 fn drop(&mut self) {
379 let mut open = match self.open.lock() {
380 Ok(open) => open,
381 Err(poisoned) => poisoned.into_inner(),
382 };
383 if let Link::Child { child, to, .. } = &mut open.link {
384 drop(to.take());
385 let _ = child.wait();
386 }
387 }
388}