surreal_sync_runtime/pipeline/external/
mod.rs1mod 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#[derive(Debug, Clone, PartialEq, Eq)]
28pub struct RetryPolicy {
29 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 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 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); 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
70pub const RELATION_WIRE_BATCH_ID_BIT: u64 = 1u64 << 63;
79
80#[inline]
82pub fn relation_wire_batch_id(batch_id: u64) -> u64 {
83 batch_id | RELATION_WIRE_BATCH_ID_BIT
84}
85
86#[derive(Clone)]
103pub struct ExternalTransform {
104 inner: Arc<ExternalInner>,
105}
106
107struct ExternalInner {
108 transport: Arc<dyn ExternalTransport>,
109 outstanding: Mutex<HashSet<u64>>,
111 timeout: Option<Duration>,
113 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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}