Skip to main content

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}