Skip to main content

surreal_sync_runtime/pipeline/external/
mod.rs

1//! External transform boundary: NDJSON wire protocol + multiplexed child-stdio.
2
3mod transport;
4mod wire;
5
6pub use transport::{
7    ChildStdioMode, ExternalTransport, PersistentChildStdio, TransientChildStdio, WireResponse,
8};
9pub use wire::{RequestHeader, ResponseHeader, WireItemKind};
10
11use anyhow::{anyhow, bail, Context, Result};
12use std::collections::HashSet;
13use std::path::PathBuf;
14use std::sync::Arc;
15use std::time::Duration;
16use surreal_sync_core::{Change, Relation, RelationChange, Row};
17use tokio::sync::Mutex;
18
19use crate::pipeline::framer::FramerKind;
20
21/// Per-stage retry/backoff for a command transform exchange.
22///
23/// `max_attempts = 1` (default) means no retry. Backoff grows exponentially
24/// from `initial_backoff` up to `max_backoff`. When `jitter` is true, each
25/// sleep is scaled by a deterministic factor in `[0.5, 1.5)` derived from the
26/// attempt number (no extra RNG dependency).
27#[derive(Debug, Clone, PartialEq, Eq)]
28pub struct RetryPolicy {
29    /// Total attempts including the first try (`>= 1`).
30    pub max_attempts: u32,
31    pub initial_backoff: Duration,
32    pub max_backoff: Duration,
33    pub jitter: bool,
34}
35
36impl Default for RetryPolicy {
37    fn default() -> Self {
38        Self {
39            max_attempts: 1,
40            initial_backoff: Duration::from_millis(200),
41            max_backoff: Duration::from_secs(30),
42            jitter: true,
43        }
44    }
45}
46
47impl RetryPolicy {
48    /// Delay before attempt `attempt` (1-based attempt that just failed).
49    pub fn backoff_after(&self, attempt: u32) -> Duration {
50        let shift = attempt.saturating_sub(1).min(16);
51        let base = self
52            .initial_backoff
53            .saturating_mul(1u32 << shift)
54            .min(self.max_backoff);
55        if !self.jitter {
56            return base;
57        }
58        // Deterministic-ish jitter without pulling in a RNG crate: mix attempt
59        // into a simple LCG over the duration nanos.
60        let nanos = base.as_nanos().max(1);
61        let mixed = nanos
62            .wrapping_mul(1103515245)
63            .wrapping_add(attempt as u128 * 12345);
64        let scale_permille = 500 + (mixed % 1000); // 500..=1499 → 0.5..=1.499
65        let jittered = nanos.saturating_mul(scale_permille) / 1000;
66        Duration::from_nanos(jittered.min(u128::from(u64::MAX)) as u64).min(self.max_backoff)
67    }
68}
69
70/// High bit set on the apply `batch_id` when an External stage issues a
71/// **relation** wire exchange that shares an apply batch with row changes.
72///
73/// Mixed change+relation batches perform two sequential NDJSON exchanges.
74/// Reusing the same wire `batch_id` for both is a footgun for outstanding-id
75/// tracking and worker scripts keyed only on `batch_id`. Relation exchanges
76/// in that path use [`relation_wire_batch_id`] instead; homogeneous relation
77/// batches keep the plain apply `batch_id`.
78pub const RELATION_WIRE_BATCH_ID_BIT: u64 = 1u64 << 63;
79
80/// Wire `batch_id` for the relation half of a mixed External exchange.
81#[inline]
82pub fn relation_wire_batch_id(batch_id: u64) -> u64 {
83    batch_id | RELATION_WIRE_BATCH_ID_BIT
84}
85
86/// External (child-stdio) transform stage.
87///
88/// surreal-sync spawns the configured worker and talks on **the worker's**
89/// stdin/stdout pipes — not the surreal-sync CLI's stdin.
90///
91/// Exchanges are multiplexed by `batch_id`: [`ExternalTransport::write_request`]
92/// / [`ExternalTransport::try_read_response`] support overlapping in-flight
93/// batches (`max_in_flight` > 1). Each waiter reads **only** its own request's
94/// response (request-keyed); payloads are never rebound onto another
95/// outstanding id. A mismatched echo fails the exchange (no sink / watermark advance).
96///
97/// # Relations
98///
99/// Relation batches use the same NDJSON framing with
100/// [`WireItemKind::RelationChange`] / [`WireItemKind::Relation`]. There is **no**
101/// silent pass-through of relation events past External stages.
102#[derive(Clone)]
103pub struct ExternalTransform {
104    inner: Arc<ExternalInner>,
105}
106
107struct ExternalInner {
108    transport: Arc<dyn ExternalTransport>,
109    /// batch_ids with a write outstanding / waiter in exchange_raw.
110    outstanding: Mutex<HashSet<u64>>,
111    /// Optional per-exchange timeout (None = rely on apply-layer timeout only).
112    timeout: Option<Duration>,
113    /// Per-stage retry/backoff (default: single attempt).
114    retry: RetryPolicy,
115}
116
117impl std::fmt::Debug for ExternalTransform {
118    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
119        f.debug_struct("ExternalTransform").finish_non_exhaustive()
120    }
121}
122
123impl ExternalTransform {
124    /// Build an external stage over an arbitrary multiplexed transport.
125    pub fn with_transport(transport: Arc<dyn ExternalTransport>) -> Self {
126        Self {
127            inner: Arc::new(ExternalInner {
128                transport,
129                outstanding: Mutex::new(HashSet::new()),
130                timeout: None,
131                retry: RetryPolicy::default(),
132            }),
133        }
134    }
135
136    /// Set per-exchange timeout for this stage (None clears it).
137    ///
138    /// Intended as a builder step at construction time (before the stage is
139    /// shared across in-flight batches).
140    pub fn with_timeout(self, timeout: Option<Duration>) -> Self {
141        Self {
142            inner: Arc::new(ExternalInner {
143                transport: Arc::clone(&self.inner.transport),
144                outstanding: Mutex::new(HashSet::new()),
145                timeout,
146                retry: self.inner.retry.clone(),
147            }),
148        }
149    }
150
151    /// Set per-stage retry/backoff policy.
152    ///
153    /// Intended as a builder step at construction time.
154    pub fn with_retry(self, retry: RetryPolicy) -> Self {
155        Self {
156            inner: Arc::new(ExternalInner {
157                transport: Arc::clone(&self.inner.transport),
158                outstanding: Mutex::new(HashSet::new()),
159                timeout: self.inner.timeout,
160                retry,
161            }),
162        }
163    }
164
165    /// Spawn a persistent child worker (default mode): one process, many batches.
166    pub fn persistent_child(command: Vec<String>, framer: FramerKind) -> Result<Self> {
167        let transport = PersistentChildStdio::spawn(command, framer)?;
168        Ok(Self::with_transport(Arc::new(transport)))
169    }
170
171    /// Transient child worker: spawn → one exchange → exit, per batch.
172    pub fn transient_child(command: Vec<String>, framer: FramerKind) -> Result<Self> {
173        let transport = TransientChildStdio::new(command, framer);
174        Ok(Self::with_transport(Arc::new(transport)))
175    }
176
177    /// Convenience from mode + argv + framer.
178    pub fn child_stdio(
179        mode: ChildStdioMode,
180        command: Vec<String>,
181        framer: FramerKind,
182    ) -> Result<Self> {
183        match mode {
184            ChildStdioMode::Persistent => Self::persistent_child(command, framer),
185            ChildStdioMode::Transient => Self::transient_child(command, framer),
186        }
187    }
188
189    /// Path helper for tests locating a fixture binary.
190    pub fn command_from_bin(bin: impl Into<PathBuf>, args: &[&str]) -> Vec<String> {
191        let mut cmd = vec![bin.into().to_string_lossy().into_owned()];
192        cmd.extend(args.iter().map(|s| (*s).to_string()));
193        cmd
194    }
195
196    /// Exchange a change batch with the worker (serialize → I/O → deserialize).
197    pub async fn exchange_changes(
198        &self,
199        batch_id: u64,
200        changes: Vec<Change>,
201    ) -> Result<Vec<Change>> {
202        let items: Vec<Vec<u8>> = changes
203            .iter()
204            .map(|c| serde_json::to_vec(c).context("serialize Change"))
205            .collect::<Result<Vec<_>>>()?;
206        let resp = self
207            .exchange_raw(batch_id, &items, WireItemKind::Change)
208            .await?;
209        resp.items
210            .into_iter()
211            .map(|bytes| serde_json::from_slice(&bytes).context("deserialize Change from worker"))
212            .collect()
213    }
214
215    /// Exchange a row batch with the worker.
216    pub async fn exchange_rows(&self, batch_id: u64, rows: Vec<Row>) -> Result<Vec<Row>> {
217        let items: Vec<Vec<u8>> = rows
218            .iter()
219            .map(|r| serde_json::to_vec(r).context("serialize Row"))
220            .collect::<Result<Vec<_>>>()?;
221        let resp = self
222            .exchange_raw(batch_id, &items, WireItemKind::Row)
223            .await?;
224        resp.items
225            .into_iter()
226            .map(|bytes| serde_json::from_slice(&bytes).context("deserialize Row from worker"))
227            .collect()
228    }
229
230    /// Exchange a relation-change batch with the worker (NDJSON wire).
231    pub async fn exchange_relation_changes(
232        &self,
233        batch_id: u64,
234        changes: Vec<RelationChange>,
235    ) -> Result<Vec<RelationChange>> {
236        let items: Vec<Vec<u8>> = changes
237            .iter()
238            .map(|c| serde_json::to_vec(c).context("serialize RelationChange"))
239            .collect::<Result<Vec<_>>>()?;
240        let resp = self
241            .exchange_raw(batch_id, &items, WireItemKind::RelationChange)
242            .await?;
243        resp.items
244            .into_iter()
245            .map(|bytes| {
246                serde_json::from_slice(&bytes).context("deserialize RelationChange from worker")
247            })
248            .collect()
249    }
250
251    /// Exchange a full-sync relation batch with the worker.
252    pub async fn exchange_relations(
253        &self,
254        batch_id: u64,
255        relations: Vec<Relation>,
256    ) -> Result<Vec<Relation>> {
257        let items: Vec<Vec<u8>> = relations
258            .iter()
259            .map(|r| serde_json::to_vec(r).context("serialize Relation"))
260            .collect::<Result<Vec<_>>>()?;
261        let resp = self
262            .exchange_raw(batch_id, &items, WireItemKind::Relation)
263            .await?;
264        resp.items
265            .into_iter()
266            .map(|bytes| serde_json::from_slice(&bytes).context("deserialize Relation from worker"))
267            .collect()
268    }
269
270    async fn exchange_raw(
271        &self,
272        batch_id: u64,
273        items: &[Vec<u8>],
274        kind: WireItemKind,
275    ) -> Result<WireResponse> {
276        {
277            let mut outstanding = self.inner.outstanding.lock().await;
278            if !outstanding.insert(batch_id) {
279                bail!("duplicate in-flight external batch_id={batch_id}");
280            }
281        }
282
283        let result = self.exchange_raw_inner(batch_id, items, kind).await;
284
285        self.inner.outstanding.lock().await.remove(&batch_id);
286        result
287    }
288
289    async fn exchange_raw_inner(
290        &self,
291        batch_id: u64,
292        items: &[Vec<u8>],
293        kind: WireItemKind,
294    ) -> Result<WireResponse> {
295        let max_attempts = self.inner.retry.max_attempts.max(1);
296        let mut attempt = 0u32;
297        loop {
298            attempt += 1;
299            let result = self.exchange_once(batch_id, items, kind).await;
300            match result {
301                Ok(resp) => return Ok(resp),
302                Err(err) if attempt < max_attempts => {
303                    let delay = self.inner.retry.backoff_after(attempt);
304                    tracing::warn!(
305                        batch_id,
306                        attempt,
307                        max_attempts,
308                        ?delay,
309                        error = %err,
310                        "command transform exchange failed; retrying after backoff"
311                    );
312                    tokio::time::sleep(delay).await;
313                }
314                Err(err) => {
315                    return Err(err).with_context(|| {
316                        format!(
317                            "command transform failed after {attempt} attempt(s) \
318                             for batch_id={batch_id}"
319                        )
320                    });
321                }
322            }
323        }
324    }
325
326    async fn exchange_once(
327        &self,
328        batch_id: u64,
329        items: &[Vec<u8>],
330        kind: WireItemKind,
331    ) -> Result<WireResponse> {
332        let fut = async {
333            self.inner
334                .transport
335                .write_request(batch_id, items, kind)
336                .await
337                .with_context(|| format!("write_request batch_id={batch_id}"))?;
338
339            let resp = self
340                .inner
341                .transport
342                .try_read_response(batch_id)
343                .await
344                .context("try_read_response")?
345                .ok_or_else(|| {
346                    anyhow!("external transport returned no response for batch_id={batch_id}")
347                })?;
348            finish_response(batch_id, resp)
349        };
350
351        if let Some(timeout) = self.inner.timeout {
352            tokio::time::timeout(timeout, fut).await.map_err(|_| {
353                anyhow!("command transform timeout after {timeout:?} for batch_id={batch_id}")
354            })?
355        } else {
356            fut.await
357        }
358    }
359}
360
361fn finish_response(expected_batch_id: u64, resp: WireResponse) -> Result<WireResponse> {
362    if resp.batch_id != expected_batch_id {
363        bail!(
364            "external transform batch_id mismatch: expected {expected_batch_id}, got {} \
365             (mismatched batch_id; no sink / watermark advance)",
366            resp.batch_id
367        );
368    }
369    if let Some(err) = resp.error {
370        return Err(anyhow!(
371            "external worker error for batch_id={expected_batch_id}: {err}"
372        ));
373    }
374    Ok(resp)
375}
376
377#[cfg(test)]
378mod retry_policy_tests {
379    use super::*;
380
381    #[test]
382    fn backoff_after_no_jitter_doubles_until_cap() {
383        let policy = RetryPolicy {
384            max_attempts: 10,
385            initial_backoff: Duration::from_millis(100),
386            max_backoff: Duration::from_millis(800),
387            jitter: false,
388        };
389        assert_eq!(policy.backoff_after(1), Duration::from_millis(100));
390        assert_eq!(policy.backoff_after(2), Duration::from_millis(200));
391        assert_eq!(policy.backoff_after(3), Duration::from_millis(400));
392        assert_eq!(policy.backoff_after(4), Duration::from_millis(800));
393        assert_eq!(policy.backoff_after(5), Duration::from_millis(800));
394        assert_eq!(policy.backoff_after(20), Duration::from_millis(800));
395    }
396
397    #[test]
398    fn backoff_after_jitter_stays_within_half_to_cap() {
399        let policy = RetryPolicy {
400            max_attempts: 10,
401            initial_backoff: Duration::from_millis(200),
402            max_backoff: Duration::from_secs(1),
403            jitter: true,
404        };
405        for attempt in 1..=8 {
406            let base = {
407                let shift = (attempt - 1).min(16);
408                policy
409                    .initial_backoff
410                    .saturating_mul(1u32 << shift)
411                    .min(policy.max_backoff)
412            };
413            let delay = policy.backoff_after(attempt);
414            // Jitter scales by [0.5, 1.5) then caps at max_backoff.
415            let min_expected = base / 2;
416            assert!(
417                delay >= min_expected,
418                "attempt {attempt}: delay {delay:?} < half of base {base:?}"
419            );
420            assert!(
421                delay <= policy.max_backoff,
422                "attempt {attempt}: delay {delay:?} exceeds max_backoff"
423            );
424        }
425        // Same attempt is deterministic (no RNG).
426        assert_eq!(policy.backoff_after(3), policy.backoff_after(3));
427    }
428
429    #[test]
430    fn backoff_after_jitter_differs_from_plain_base() {
431        let policy = RetryPolicy {
432            max_attempts: 5,
433            initial_backoff: Duration::from_millis(1000),
434            max_backoff: Duration::from_secs(60),
435            jitter: true,
436        };
437        let no_jitter = RetryPolicy {
438            jitter: false,
439            ..policy.clone()
440        };
441        // At least one of the early attempts should differ once jitter mixes.
442        let differs = (1..=4).any(|a| policy.backoff_after(a) != no_jitter.backoff_after(a));
443        assert!(differs, "jitter should change at least one backoff delay");
444    }
445}