Skip to main content

surreal_sync_runtime/pipeline/external/
transport.rs

1//! Multiplexed external transports: persistent/transient child-stdio.
2
3use crate::pipeline::external::wire::{RequestHeader, ResponseHeader, WireItemKind};
4use crate::pipeline::framer::{Framer, FramerKind, NdjsonFramer};
5use anyhow::{anyhow, bail, Context, Result};
6use async_trait::async_trait;
7use bytes::BytesMut;
8use std::collections::{HashMap, VecDeque};
9use std::process::Stdio;
10use std::sync::Arc;
11use tokio::io::{AsyncReadExt, AsyncWriteExt};
12use tokio::process::{Child, ChildStdin, ChildStdout, Command};
13use tokio::sync::{Mutex, Notify};
14
15/// Whether the worker process lives across batches or is respawned each time.
16#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
17pub enum ChildStdioMode {
18    /// Spawn once; many exchanges on the same pipes (default).
19    #[default]
20    Persistent,
21    /// Spawn → one batch exchange → wait for exit; repeat per batch.
22    Transient,
23}
24
25/// One complete worker response (header + item payloads).
26#[derive(Debug, Clone)]
27pub struct WireResponse {
28    pub batch_id: u64,
29    pub error: Option<String>,
30    pub items: Vec<Vec<u8>>,
31}
32
33/// Multiplexed byte-oriented external transport.
34///
35/// Even when `max_in_flight = 1`, apply uses this API (not a “last request
36/// wins” single-slot exchange). Responses are correlated by the **request**
37/// `batch_id` passed to [`Self::try_read_response`] — never by rebinding a
38/// payload read for one waiter onto another outstanding id.
39#[async_trait]
40pub trait ExternalTransport: Send + Sync {
41    /// Write one framed request (header + item lines) for `batch_id`.
42    ///
43    /// `kind` is included in the NDJSON header so workers can distinguish
44    /// row-change vs relation payloads.
45    async fn write_request(
46        &self,
47        batch_id: u64,
48        items: &[Vec<u8>],
49        kind: WireItemKind,
50    ) -> Result<()>;
51
52    /// Await the response for a specific request `batch_id`.
53    ///
54    /// Returns `Ok(None)` only when there is nothing to wait on yet (rare;
55    /// most implementations block until this request’s response is ready).
56    /// The returned [`WireResponse::batch_id`] must still echo the request id
57    /// (checked by [`crate::pipeline::ExternalTransform`]).
58    async fn try_read_response(&self, batch_id: u64) -> Result<Option<WireResponse>>;
59}
60
61pub(crate) fn encode_request(
62    framer: &dyn Framer,
63    batch_id: u64,
64    items: &[Vec<u8>],
65    kind: WireItemKind,
66) -> Vec<u8> {
67    let header = RequestHeader {
68        batch_id,
69        count: items.len(),
70        kind,
71    };
72    let header_bytes = serde_json::to_vec(&header).expect("RequestHeader serializes");
73    let mut out = Vec::with_capacity(
74        header_bytes.len() + 1 + items.iter().map(|i| i.len() + 1).sum::<usize>(),
75    );
76    framer.write_message(&header_bytes, &mut out);
77    for item in items {
78        framer.write_message(item, &mut out);
79    }
80    out
81}
82
83async fn write_all_locked(stdin: &Mutex<ChildStdin>, bytes: &[u8]) -> Result<()> {
84    let mut guard = stdin.lock().await;
85    guard.write_all(bytes).await.context("write worker stdin")?;
86    guard.flush().await.context("flush worker stdin")?;
87    Ok(())
88}
89
90/// Pending header when item lines are not yet complete.
91struct PendingHeader {
92    batch_id: u64,
93    count: usize,
94    items: Vec<Vec<u8>>,
95    /// When set, items are drained then an error [`WireResponse`] is returned.
96    error: Option<String>,
97}
98
99struct ReadState {
100    stdout: ChildStdout,
101    buf: BytesMut,
102    pending: Option<PendingHeader>,
103}
104
105impl ReadState {
106    fn new(stdout: ChildStdout) -> Self {
107        Self {
108            stdout,
109            buf: BytesMut::with_capacity(8 * 1024),
110            pending: None,
111        }
112    }
113
114    /// Block until one complete [`WireResponse`] is available.
115    async fn read_response(&mut self, framer: &dyn Framer) -> Result<WireResponse> {
116        loop {
117            if let Some(resp) = self.try_parse(framer)? {
118                return Ok(resp);
119            }
120            let n = self
121                .stdout
122                .read_buf(&mut self.buf)
123                .await
124                .context("read worker stdout")?;
125            if n == 0 {
126                if self.buf.is_empty() && self.pending.is_none() {
127                    bail!("external worker stdout EOF while waiting for response");
128                }
129                bail!(
130                    "external worker stdout EOF with incomplete NDJSON ({} bytes buffered)",
131                    self.buf.len()
132                );
133            }
134        }
135    }
136
137    fn try_parse(&mut self, framer: &dyn Framer) -> Result<Option<WireResponse>> {
138        try_parse_buf(&mut self.buf, &mut self.pending, framer)
139    }
140}
141
142/// Shared NDJSON response parse (used by child-stdio readers and unit tests).
143fn try_parse_buf(
144    buf: &mut BytesMut,
145    pending: &mut Option<PendingHeader>,
146    framer: &dyn Framer,
147) -> Result<Option<WireResponse>> {
148    loop {
149        if let Some(mut p) = pending.take() {
150            while p.items.len() < p.count {
151                match framer.next_message(buf)? {
152                    Some(item) => p.items.push(item.to_vec()),
153                    None => {
154                        *pending = Some(p);
155                        return Ok(None);
156                    }
157                }
158            }
159            if let Some(error) = p.error {
160                // Trailing item lines after an error header are drained so
161                // the next header stays framed; payload is discarded.
162                return Ok(Some(WireResponse {
163                    batch_id: p.batch_id,
164                    error: Some(error),
165                    items: Vec::new(),
166                }));
167            }
168            return Ok(Some(WireResponse {
169                batch_id: p.batch_id,
170                error: None,
171                items: p.items,
172            }));
173        }
174
175        let Some(header_bytes) = framer.next_message(buf)? else {
176            return Ok(None);
177        };
178
179        let header: ResponseHeader = serde_json::from_slice(&header_bytes).with_context(|| {
180            format!(
181                "invalid external response header JSON: {}",
182                String::from_utf8_lossy(&header_bytes)
183            )
184        })?;
185
186        if header.error.is_some() {
187            // Prefer draining declared trailing items to avoid desync; if
188            // count is absent, assume a compliant worker sent no items.
189            let drain = header.count.unwrap_or(0);
190            if drain > 0 {
191                *pending = Some(PendingHeader {
192                    batch_id: header.batch_id,
193                    count: drain,
194                    items: Vec::with_capacity(drain),
195                    error: header.error,
196                });
197                continue;
198            }
199            return Ok(Some(WireResponse {
200                batch_id: header.batch_id,
201                error: header.error,
202                items: Vec::new(),
203            }));
204        }
205
206        let count = header.count.ok_or_else(|| {
207            anyhow!(
208                "external response for batch_id={} missing both count and error",
209                header.batch_id
210            )
211        })?;
212
213        *pending = Some(PendingHeader {
214            batch_id: header.batch_id,
215            count,
216            items: Vec::with_capacity(count),
217            error: None,
218        });
219    }
220}
221
222/// Persistent child: spawn once, keep alive for many exchanges.
223///
224/// # Pairing / reliability
225///
226/// Responses are paired to requests by **write order** on the shared pipe
227/// (fail closed if the echoed `batch_id` does not match the dequeued write).
228/// That prevents rebinding one request’s waiter onto another outstanding id
229/// when a confused worker reorders or mis-labels responses.
230///
231/// Only one task holds [`Self`]'s read gate while advancing stdout; after a
232/// framed response is stored (or returned), the gate is released so peer
233/// waiters can drive the next read — the gate is **not** held across peer
234/// notify waits.
235///
236/// # Threat model (accepted)
237///
238/// A **malicious** worker that answers in write order while echoing the
239/// correct `batch_id` for each dequeued write, but attaching another batch’s
240/// transformed payload, cannot be detected on a single stdio pipe (transforms
241/// change bytes, so request/response content hashing is not a viable check).
242/// surreal-sync trusts the worker’s integrity for payload↔id binding; the
243/// fail-closed path targets buggy / accidental identity confusion, not a
244/// hostile worker. Prefer honest workers; see integration tests for FIFO
245/// multiplex and colliding mismatch fail-closed.
246pub struct PersistentChildStdio {
247    child: Mutex<Child>,
248    stdin: Mutex<ChildStdin>,
249    write_order: Mutex<VecDeque<u64>>,
250    completed: Mutex<HashMap<u64, Result<WireResponse>>>,
251    read: Mutex<ReadState>,
252    /// Wakes waiters when a request-keyed slot may be ready.
253    notify: Notify,
254    /// Only one task advances the shared stdout stream.
255    read_gate: Mutex<()>,
256    framer: NdjsonFramer,
257}
258
259impl PersistentChildStdio {
260    /// Spawn `command[0]` with `command[1..]` as args; pipe stdin/stdout.
261    pub fn spawn(command: Vec<String>, framer: FramerKind) -> Result<Self> {
262        if command.is_empty() {
263            bail!("persistent child command must not be empty");
264        }
265        let framer = framer.into_framer();
266        let program = &command[0];
267        let mut cmd = Command::new(program);
268        if command.len() > 1 {
269            cmd.args(&command[1..]);
270        }
271        cmd.stdin(Stdio::piped())
272            .stdout(Stdio::piped())
273            .stderr(Stdio::inherit())
274            .kill_on_drop(true);
275
276        let mut child = cmd
277            .spawn()
278            .with_context(|| format!("spawn persistent worker: {command:?}"))?;
279        let stdin = child
280            .stdin
281            .take()
282            .ok_or_else(|| anyhow!("worker stdin pipe missing"))?;
283        let stdout = child
284            .stdout
285            .take()
286            .ok_or_else(|| anyhow!("worker stdout pipe missing"))?;
287
288        Ok(Self {
289            child: Mutex::new(child),
290            stdin: Mutex::new(stdin),
291            write_order: Mutex::new(VecDeque::new()),
292            completed: Mutex::new(HashMap::new()),
293            read: Mutex::new(ReadState::new(stdout)),
294            notify: Notify::new(),
295            read_gate: Mutex::new(()),
296            framer,
297        })
298    }
299}
300
301#[async_trait]
302impl ExternalTransport for PersistentChildStdio {
303    async fn write_request(
304        &self,
305        batch_id: u64,
306        items: &[Vec<u8>],
307        kind: WireItemKind,
308    ) -> Result<()> {
309        let bytes = encode_request(&self.framer, batch_id, items, kind);
310        self.write_order.lock().await.push_back(batch_id);
311        write_all_locked(&self.stdin, &bytes).await?;
312        self.notify.notify_waiters();
313        Ok(())
314    }
315
316    async fn try_read_response(&self, batch_id: u64) -> Result<Option<WireResponse>> {
317        loop {
318            // Register before checking to avoid lost wakeups.
319            let notified = self.notify.notified();
320            {
321                let mut completed = self.completed.lock().await;
322                if let Some(resp) = completed.remove(&batch_id) {
323                    return resp.map(Some);
324                }
325            }
326
327            let gate = self.read_gate.try_lock();
328            let Ok(_gate) = gate else {
329                notified.await;
330                continue;
331            };
332
333            // Recheck under gate — another reader may have filled our slot.
334            {
335                let mut completed = self.completed.lock().await;
336                if let Some(resp) = completed.remove(&batch_id) {
337                    return resp.map(Some);
338                }
339            }
340
341            let req_id = {
342                let mut q = self.write_order.lock().await;
343                if q.is_empty() {
344                    drop(_gate);
345                    notified.await;
346                    continue;
347                }
348                q.pop_front().expect("non-empty")
349            };
350
351            // Blocking read without holding write_order / completed.
352            let framed = {
353                let mut read = self.read.lock().await;
354                read.read_response(&self.framer).await
355            };
356
357            let result = match framed {
358                Ok(resp) if resp.batch_id == req_id => Ok(resp),
359                Ok(resp) => Err(anyhow!(
360                    "external transform batch_id mismatch: expected request {req_id}, \
361                     got response for {} (fail closed; no cross-batch rebind)",
362                    resp.batch_id
363                )),
364                Err(e) => Err(e),
365            };
366
367            if req_id == batch_id {
368                self.notify.notify_waiters();
369                return result.map(Some);
370            }
371            self.completed.lock().await.insert(req_id, result);
372            self.notify.notify_waiters();
373            // Another waiter's response — check whether ours was filled as a
374            // side effect, then drop the read gate so peers can drive stdout.
375            {
376                let mut completed = self.completed.lock().await;
377                if let Some(resp) = completed.remove(&batch_id) {
378                    return resp.map(Some);
379                }
380            }
381            drop(_gate);
382        }
383    }
384}
385
386impl Drop for PersistentChildStdio {
387    fn drop(&mut self) {
388        if let Ok(mut child) = self.child.try_lock() {
389            let _ = child.start_kill();
390        }
391    }
392}
393
394/// Transient child: one process per batch exchange.
395///
396/// `write_request` spawns and writes; `try_read_response` reads that child's
397/// response and waits for exit. Overlapping batches each get their own child
398/// (limited usefulness vs persistent when `max_in_flight` is large).
399pub struct TransientChildStdio {
400    command: Vec<String>,
401    framer: NdjsonFramer,
402    /// In-flight children keyed by request batch_id.
403    inflight: Mutex<HashMap<u64, TransientChild>>,
404    notify: Arc<Notify>,
405}
406
407struct TransientChild {
408    child: Child,
409    read: ReadState,
410}
411
412impl TransientChildStdio {
413    pub fn new(command: Vec<String>, framer: FramerKind) -> Self {
414        Self {
415            command,
416            framer: framer.into_framer(),
417            inflight: Mutex::new(HashMap::new()),
418            notify: Arc::new(Notify::new()),
419        }
420    }
421
422    fn spawn_one(&self) -> Result<(ChildStdin, TransientChild)> {
423        if self.command.is_empty() {
424            bail!("transient child command must not be empty");
425        }
426        let program = &self.command[0];
427        let mut cmd = Command::new(program);
428        if self.command.len() > 1 {
429            cmd.args(&self.command[1..]);
430        }
431        cmd.stdin(Stdio::piped())
432            .stdout(Stdio::piped())
433            .stderr(Stdio::inherit())
434            .kill_on_drop(true);
435
436        let mut child = cmd
437            .spawn()
438            .with_context(|| format!("spawn transient worker: {:?}", self.command))?;
439        let stdin = child
440            .stdin
441            .take()
442            .ok_or_else(|| anyhow!("worker stdin pipe missing"))?;
443        let stdout = child
444            .stdout
445            .take()
446            .ok_or_else(|| anyhow!("worker stdout pipe missing"))?;
447        Ok((
448            stdin,
449            TransientChild {
450                child,
451                read: ReadState::new(stdout),
452            },
453        ))
454    }
455}
456
457#[async_trait]
458impl ExternalTransport for TransientChildStdio {
459    async fn write_request(
460        &self,
461        batch_id: u64,
462        items: &[Vec<u8>],
463        kind: WireItemKind,
464    ) -> Result<()> {
465        let (mut stdin, tc) = self.spawn_one()?;
466        let bytes = encode_request(&self.framer, batch_id, items, kind);
467        stdin
468            .write_all(&bytes)
469            .await
470            .context("write transient worker stdin")?;
471        stdin
472            .flush()
473            .await
474            .context("flush transient worker stdin")?;
475        // Close stdin so workers that read until EOF still complete.
476        drop(stdin);
477        self.inflight.lock().await.insert(batch_id, tc);
478        self.notify.notify_waiters();
479        Ok(())
480    }
481
482    async fn try_read_response(&self, batch_id: u64) -> Result<Option<WireResponse>> {
483        loop {
484            // Register before checking to avoid lost wakeups when write races.
485            let notified = self.notify.notified();
486            let tc = {
487                let mut inflight = self.inflight.lock().await;
488                inflight.remove(&batch_id)
489            };
490            let Some(mut tc) = tc else {
491                notified.await;
492                continue;
493            };
494
495            // Read/wait without holding the inflight map (other batches can write).
496            let resp = tc.read.read_response(&self.framer).await?;
497            let status = tc
498                .child
499                .wait()
500                .await
501                .context("wait transient worker exit")?;
502            if !status.success() {
503                bail!("transient worker exited with {status} for batch_id={batch_id}");
504            }
505            return Ok(Some(resp));
506        }
507    }
508}
509
510#[cfg(test)]
511mod tests {
512    use super::*;
513    use crate::pipeline::framer::NdjsonFramer;
514
515    #[test]
516    fn error_header_drains_trailing_item_lines() {
517        let framer = NdjsonFramer;
518        let mut out = Vec::new();
519        // error + count=2 with two trailing item lines, then a normal success header.
520        framer.write_message(br#"{"batch_id":1,"error":"boom","count":2}"#, &mut out);
521        framer.write_message(br#"{"junk":1}"#, &mut out);
522        framer.write_message(br#"{"junk":2}"#, &mut out);
523        framer.write_message(br#"{"batch_id":2,"count":0}"#, &mut out);
524        let mut buf = BytesMut::from(out.as_slice());
525        let mut pending = None;
526
527        let err_resp = try_parse_buf(&mut buf, &mut pending, &framer)
528            .unwrap()
529            .expect("error response");
530        assert_eq!(err_resp.batch_id, 1);
531        assert_eq!(err_resp.error.as_deref(), Some("boom"));
532        assert!(err_resp.items.is_empty());
533        assert!(pending.is_none());
534
535        let ok_resp = try_parse_buf(&mut buf, &mut pending, &framer)
536            .unwrap()
537            .expect("follow-up response");
538        assert_eq!(ok_resp.batch_id, 2);
539        assert!(ok_resp.error.is_none());
540        assert!(ok_resp.items.is_empty());
541    }
542
543    #[test]
544    fn encode_request_omits_default_change_kind() {
545        let framer = NdjsonFramer;
546        let bytes = encode_request(&framer, 7, &[b"{}".to_vec()], WireItemKind::Change);
547        let header_line = bytes.split(|&b| b == b'\n').next().unwrap();
548        let header: RequestHeader = serde_json::from_slice(header_line).unwrap();
549        assert_eq!(header.batch_id, 7);
550        assert_eq!(header.count, 1);
551        assert_eq!(header.kind, WireItemKind::Change);
552        let raw: serde_json::Value = serde_json::from_slice(header_line).unwrap();
553        assert!(
554            raw.get("kind").is_none(),
555            "default Change kind must be omitted for back-compat: {raw}"
556        );
557    }
558
559    #[test]
560    fn encode_request_includes_relation_change_kind() {
561        let framer = NdjsonFramer;
562        let bytes = encode_request(&framer, 3, &[b"{}".to_vec()], WireItemKind::RelationChange);
563        let header_line = bytes.split(|&b| b == b'\n').next().unwrap();
564        let header: RequestHeader = serde_json::from_slice(header_line).unwrap();
565        assert_eq!(header.kind, WireItemKind::RelationChange);
566        let raw: serde_json::Value = serde_json::from_slice(header_line).unwrap();
567        assert_eq!(
568            raw.get("kind").and_then(|v| v.as_str()),
569            Some("relation_change"),
570            "RelationChange must be serialized on the wire: {raw}"
571        );
572    }
573}