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}