Skip to main content

surreal_sync_runtime/pipeline/
pipeline.rs

1//! Ordered transform pipeline: in-place stages and external boundary.
2
3use crate::pipeline::apply::ApplyEvent;
4use crate::pipeline::external::ExternalTransform;
5use anyhow::{bail, Result};
6use std::sync::Arc;
7use surreal_sync_core::InPlaceTransform;
8use surreal_sync_core::{Change, Relation, RelationChange, Row};
9
10/// A single pipeline stage.
11#[derive(Clone)]
12pub enum Stage {
13    /// In-process mutate-only transform.
14    InPlace(Arc<dyn InPlaceTransform>),
15    /// External worker boundary (child-stdio NDJSON).
16    External(ExternalTransform),
17}
18
19impl std::fmt::Debug for Stage {
20    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
21        match self {
22            Stage::InPlace(_) => f.write_str("InPlace(_)"),
23            Stage::External(ext) => f.debug_tuple("External").field(ext).finish(),
24        }
25    }
26}
27
28/// Ordered list of transform stages.
29///
30/// An empty pipeline is **identity**: [`is_identity`](Self::is_identity) is
31/// true and apply helpers return immediately without dispatching any stage.
32///
33/// # Identity vs passthrough
34///
35/// Pushing a lone [`crate::pipeline::Passthrough`] via [`push_inplace`](Self::push_inplace)
36/// does **not** make [`is_identity`](Self::is_identity) return `true` — the
37/// pipeline still has a stage and will dispatch into it. TOML config loading
38/// ([`crate::pipeline::Pipeline::from_config`] / [`crate::pipeline::parse_transforms_toml`])
39/// collapses passthrough-only configs to an empty pipeline so the CLI identity
40/// path stays zero-dispatch.
41///
42/// # Apply path
43///
44/// Shared apply loop: transform → ordered write → watermark.
45/// [`crate::pipeline::ApplyContext`] / [`crate::pipeline::SourceDriver`] gate on
46/// [`crate::pipeline::BatchTransformer::is_identity`] (implemented for [`Pipeline`] via
47/// [`is_identity`](Self::is_identity)) so the transform [`tokio::task::JoinSet`]
48/// can take an async no-op path (zero stage dispatch). That is **not** a
49/// write-path bypass: identity batches still go through the ordered sink step
50/// (including homogeneous `Update` coalesce to `write_universal_*`). Only an
51/// empty stage list is identity — not “stages happen to be no-ops.”
52///
53/// # Schema-aware / FK transforms
54///
55/// Construct an [`InPlaceTransform`] with schema (e.g. FK → `Thing` links) and
56/// [`push_inplace`](Self::push_inplace) it. Relation edges use the same
57/// pipeline via relation transform methods; full join-table→relation conversion
58/// may still live in a source crate.
59///
60/// # External + relations
61///
62/// External stages exchange **both** row changes and relation changes over
63/// NDJSON ([`ExternalTransform::exchange_relation_changes`] /
64/// [`ExternalTransform::exchange_relations`]). There is no silent relation
65/// pass-through. Mixed change+relation batches may not filter/fan-out (length
66/// of each kind must be preserved); use homogeneous batches for length changes.
67///
68/// When a single External stage sees a **mixed** change+relation batch, the two
69/// wire exchanges use distinct `batch_id`s: changes keep the apply `batch_id`,
70/// relations use [`crate::pipeline::relation_wire_batch_id`] (high bit set) so workers and
71/// outstanding-id tracking never confuse the two sequential exchanges.
72#[derive(Debug, Default, Clone)]
73pub struct Pipeline {
74    stages: Vec<Stage>,
75}
76
77impl Pipeline {
78    /// Create an empty (identity) pipeline.
79    pub fn new() -> Self {
80        Self { stages: Vec::new() }
81    }
82
83    /// Whether this pipeline has no stages (identity / no stage dispatch).
84    ///
85    /// Only an empty stage list is identity. A pipeline that contains only
86    /// [`crate::pipeline::Passthrough`] still returns `false` here — see type-level docs.
87    pub fn is_identity(&self) -> bool {
88        self.stages.is_empty()
89    }
90
91    /// Number of stages (0 = identity).
92    pub fn len(&self) -> usize {
93        self.stages.len()
94    }
95
96    /// Whether there are no stages.
97    pub fn is_empty(&self) -> bool {
98        self.stages.is_empty()
99    }
100
101    /// Borrow the stage list.
102    pub fn stages(&self) -> &[Stage] {
103        &self.stages
104    }
105
106    /// Append an in-place transform stage (library / embedder API).
107    ///
108    /// Note: appending [`crate::pipeline::Passthrough`] alone does not yield an identity
109    /// pipeline ([`is_identity`](Self::is_identity) stays `false`).
110    pub fn push_inplace<T>(&mut self, transform: T)
111    where
112        T: InPlaceTransform + 'static,
113    {
114        self.stages.push(Stage::InPlace(Arc::new(transform)));
115    }
116
117    /// Append a pre-boxed in-place stage.
118    pub fn push_inplace_arc(&mut self, transform: Arc<dyn InPlaceTransform>) {
119        self.stages.push(Stage::InPlace(transform));
120    }
121
122    /// Append an external (child-stdio) stage.
123    pub fn push_external(&mut self, external: ExternalTransform) {
124        self.stages.push(Stage::External(external));
125    }
126
127    /// Transform owned rows in place (sync path — **in-place stages only**).
128    ///
129    /// Empty pipeline: no-op with no stage dispatch. External stages are not
130    /// supported here; use [`crate::pipeline::BatchTransformer::transform_rows`] (async).
131    pub fn transform_rows_inplace(&self, rows: &mut [Row]) -> Result<()> {
132        if self.is_identity() {
133            return Ok(());
134        }
135        for stage in &self.stages {
136            match stage {
137                Stage::InPlace(t) => t.transform_rows_inplace(rows)?,
138                Stage::External(_) => {
139                    bail!(
140                        "External transforms require the async BatchTransformer path \
141                         (transform_rows); sync inplace apply is in-place-only"
142                    )
143                }
144            }
145        }
146        Ok(())
147    }
148
149    /// Transform owned changes in place (sync path — **in-place stages only**).
150    ///
151    /// Empty pipeline: no-op with no stage dispatch. External stages are not
152    /// supported here; use [`crate::pipeline::BatchTransformer::transform_changes`] (async).
153    pub fn transform_changes_inplace(&self, changes: &mut [Change]) -> Result<()> {
154        if self.is_identity() {
155            return Ok(());
156        }
157        for stage in &self.stages {
158            match stage {
159                Stage::InPlace(t) => t.transform_changes_inplace(changes)?,
160                Stage::External(_) => {
161                    bail!(
162                        "External transforms require the async BatchTransformer path \
163                         (transform_changes); sync inplace apply is in-place-only"
164                    )
165                }
166            }
167        }
168        Ok(())
169    }
170
171    /// Consume an owned row batch, transform in place, and return it.
172    ///
173    /// Preferred path for **in-place-only** pipelines: empty
174    /// pipeline is a pure move with no transform dispatch.
175    pub fn apply_rows(&self, mut rows: Vec<Row>) -> Result<Vec<Row>> {
176        self.transform_rows_inplace(&mut rows)?;
177        Ok(rows)
178    }
179
180    /// Consume an owned change batch, transform in place, and return it.
181    pub fn apply_changes(&self, mut changes: Vec<Change>) -> Result<Vec<Change>> {
182        self.transform_changes_inplace(&mut changes)?;
183        Ok(changes)
184    }
185
186    /// Async stage walk used by [`crate::pipeline::BatchTransformer`]: in-place mutates,
187    /// External exchanges over child-stdio (may change batch length).
188    pub(crate) async fn apply_changes_async(
189        &self,
190        batch_id: u64,
191        mut changes: Vec<Change>,
192    ) -> Result<Vec<Change>> {
193        if self.is_identity() {
194            return Ok(changes);
195        }
196        for stage in &self.stages {
197            match stage {
198                Stage::InPlace(t) => t.transform_changes_inplace(&mut changes)?,
199                Stage::External(ext) => {
200                    changes = ext.exchange_changes(batch_id, changes).await?;
201                }
202            }
203        }
204        Ok(changes)
205    }
206
207    /// Async stage walk for rows (see [`Self::apply_changes_async`]).
208    pub(crate) async fn apply_rows_async(
209        &self,
210        batch_id: u64,
211        mut rows: Vec<Row>,
212    ) -> Result<Vec<Row>> {
213        if self.is_identity() {
214            return Ok(rows);
215        }
216        for stage in &self.stages {
217            match stage {
218                Stage::InPlace(t) => t.transform_rows_inplace(&mut rows)?,
219                Stage::External(ext) => {
220                    rows = ext.exchange_rows(batch_id, rows).await?;
221                }
222            }
223        }
224        Ok(rows)
225    }
226
227    /// Transform owned relation changes in place (sync — **in-place stages only**).
228    pub fn transform_relation_changes_inplace(&self, changes: &mut [RelationChange]) -> Result<()> {
229        if self.is_identity() {
230            return Ok(());
231        }
232        for stage in &self.stages {
233            match stage {
234                Stage::InPlace(t) => t.transform_relation_changes_inplace(changes)?,
235                Stage::External(_) => {
236                    bail!(
237                        "External transforms require the async BatchTransformer path \
238                         (transform_relation_changes); sync inplace apply is in-place-only"
239                    )
240                }
241            }
242        }
243        Ok(())
244    }
245
246    /// Transform owned relations in place (sync — **in-place stages only**).
247    pub fn transform_relations_inplace(&self, relations: &mut [Relation]) -> Result<()> {
248        if self.is_identity() {
249            return Ok(());
250        }
251        for stage in &self.stages {
252            match stage {
253                Stage::InPlace(t) => t.transform_relations_inplace(relations)?,
254                Stage::External(_) => {
255                    bail!(
256                        "External transforms require the async BatchTransformer path \
257                         (transform_relations); sync inplace apply is in-place-only"
258                    )
259                }
260            }
261        }
262        Ok(())
263    }
264
265    /// Consume owned relation changes, transform in place, return them.
266    pub fn apply_relation_changes(
267        &self,
268        mut changes: Vec<RelationChange>,
269    ) -> Result<Vec<RelationChange>> {
270        self.transform_relation_changes_inplace(&mut changes)?;
271        Ok(changes)
272    }
273
274    /// Consume owned relations, transform in place, return them.
275    pub fn apply_relations(&self, mut relations: Vec<Relation>) -> Result<Vec<Relation>> {
276        self.transform_relations_inplace(&mut relations)?;
277        Ok(relations)
278    }
279
280    pub(crate) async fn apply_relation_changes_async(
281        &self,
282        batch_id: u64,
283        mut changes: Vec<RelationChange>,
284    ) -> Result<Vec<RelationChange>> {
285        if self.is_identity() {
286            return Ok(changes);
287        }
288        for stage in &self.stages {
289            match stage {
290                Stage::InPlace(t) => t.transform_relation_changes_inplace(&mut changes)?,
291                Stage::External(ext) => {
292                    changes = ext.exchange_relation_changes(batch_id, changes).await?;
293                }
294            }
295        }
296        Ok(changes)
297    }
298
299    pub(crate) async fn apply_relations_async(
300        &self,
301        batch_id: u64,
302        mut relations: Vec<Relation>,
303    ) -> Result<Vec<Relation>> {
304        if self.is_identity() {
305            return Ok(relations);
306        }
307        for stage in &self.stages {
308            match stage {
309                Stage::InPlace(t) => t.transform_relations_inplace(&mut relations)?,
310                Stage::External(ext) => {
311                    relations = ext.exchange_relations(batch_id, relations).await?;
312                }
313            }
314        }
315        Ok(relations)
316    }
317
318    /// Mixed event walk: InPlace applies to each kind; External exchanges **both**
319    /// row changes and relation changes over NDJSON (no silent pass-through).
320    ///
321    /// Homogeneous batches may filter/fan-out (length may change). Mixed
322    /// change+relation batches must preserve length of each kind.
323    ///
324    /// Mixed External exchanges use distinct wire `batch_id`s (see
325    /// [`crate::pipeline::relation_wire_batch_id`]) so change vs relation requests never
326    /// share an id.
327    pub(crate) async fn apply_events_async(
328        &self,
329        batch_id: u64,
330        mut events: Vec<ApplyEvent>,
331    ) -> Result<Vec<ApplyEvent>> {
332        if self.is_identity() {
333            return Ok(events);
334        }
335        for stage in &self.stages {
336            match stage {
337                Stage::InPlace(t) => {
338                    for event in &mut events {
339                        match event {
340                            ApplyEvent::Change(c) => t.transform_change(c)?,
341                            ApplyEvent::RelationChange(r) => t.transform_relation_change(r)?,
342                        }
343                    }
344                }
345                Stage::External(ext) => {
346                    let all_changes = events.iter().all(|e| e.is_change());
347                    let all_rels = events.iter().all(|e| e.is_relation_change());
348                    if all_changes {
349                        let changes: Vec<Change> = events
350                            .into_iter()
351                            .map(|e| match e {
352                                ApplyEvent::Change(c) => c,
353                                ApplyEvent::RelationChange(_) => unreachable!(),
354                            })
355                            .collect();
356                        let transformed = ext.exchange_changes(batch_id, changes).await?;
357                        events = transformed.into_iter().map(ApplyEvent::Change).collect();
358                    } else if all_rels {
359                        let rels: Vec<RelationChange> = events
360                            .into_iter()
361                            .map(|e| match e {
362                                ApplyEvent::RelationChange(r) => *r,
363                                ApplyEvent::Change(_) => unreachable!(),
364                            })
365                            .collect();
366                        let transformed = ext.exchange_relation_changes(batch_id, rels).await?;
367                        events = transformed
368                            .into_iter()
369                            .map(ApplyEvent::relation_change)
370                            .collect();
371                    } else {
372                        let mut change_idxs = Vec::new();
373                        let mut changes = Vec::new();
374                        let mut rel_idxs = Vec::new();
375                        let mut rels = Vec::new();
376                        for (i, event) in events.iter().enumerate() {
377                            match event {
378                                ApplyEvent::Change(c) => {
379                                    change_idxs.push(i);
380                                    changes.push(c.clone());
381                                }
382                                ApplyEvent::RelationChange(r) => {
383                                    rel_idxs.push(i);
384                                    rels.push((**r).clone());
385                                }
386                            }
387                        }
388                        // Distinct wire ids: reuse of `batch_id` for both kinds
389                        // would collide in outstanding tracking / worker scripts.
390                        let rel_batch_id = crate::pipeline::relation_wire_batch_id(batch_id);
391                        if !changes.is_empty() {
392                            let n = changes.len();
393                            let transformed = ext.exchange_changes(batch_id, changes).await?;
394                            if transformed.len() != n {
395                                bail!(
396                                    "External stage changed change-count ({n} → {}) while relation \
397                                     events were present in the same batch; use homogeneous batches \
398                                     for filter/fan-out",
399                                    transformed.len()
400                                );
401                            }
402                            for (idx, c) in change_idxs.into_iter().zip(transformed) {
403                                events[idx] = ApplyEvent::Change(c);
404                            }
405                        }
406                        if !rels.is_empty() {
407                            let n = rels.len();
408                            let transformed =
409                                ext.exchange_relation_changes(rel_batch_id, rels).await?;
410                            if transformed.len() != n {
411                                bail!(
412                                    "External stage changed relation-count ({n} → {}) while row \
413                                     changes were present in the same batch; use homogeneous batches \
414                                     for filter/fan-out",
415                                    transformed.len()
416                                );
417                            }
418                            for (idx, r) in rel_idxs.into_iter().zip(transformed) {
419                                events[idx] = ApplyEvent::relation_change(r);
420                            }
421                        }
422                    }
423                }
424            }
425        }
426        Ok(events)
427    }
428}