1use 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#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
17pub enum ChildStdioMode {
18 #[default]
20 Persistent,
21 Transient,
23}
24
25#[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#[async_trait]
40pub trait ExternalTransport: Send + Sync {
41 async fn write_request(
46 &self,
47 batch_id: u64,
48 items: &[Vec<u8>],
49 kind: WireItemKind,
50 ) -> Result<()>;
51
52 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
90struct PendingHeader {
92 batch_id: u64,
93 count: usize,
94 items: Vec<Vec<u8>>,
95 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 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
142fn 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 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 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
222pub 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 notify: Notify,
254 read_gate: Mutex<()>,
256 framer: NdjsonFramer,
257}
258
259impl PersistentChildStdio {
260 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 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 {
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 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 {
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
394pub struct TransientChildStdio {
400 command: Vec<String>,
401 framer: NdjsonFramer,
402 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 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 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 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 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}