Skip to main content

proxima_primitives/pipe/
diff.rs

1use std::future::Future;
2
3use bytes::Bytes;
4
5use crate::transport::{DEFAULT_REPLAY_CAP_BYTES, Replay};
6
7use crate::pipe::Method;
8use crate::pipe::ProximaError;
9use crate::pipe::SendPipe;
10use crate::pipe::body::{ChunkStream, RequestStream};
11use crate::pipe::handler::{Handler, PipeHandle, ThreadLocalPipeHandle, into_handle};
12use crate::pipe::primitives::Pipe;
13use crate::pipe::request::{Request, Response};
14use crate::pipe::telemetry_surface::Labels;
15
16const COUNTER_IDENTICAL: &str = "proxima.diff.identical_total";
17const COUNTER_DIVERGENT: &str = "proxima.diff.divergent_total";
18
19/// fan-out → fan-in diff. send the same Request through `left` and
20/// `right`, collect both responses, emit a single JSON diff describing
21/// where they disagree. used to A/B two implementations against the
22/// same input — record once, run both, see which bytes diverged.
23///
24/// the request body is teed (via the existing backpressure-aware tee) so
25/// both branches see identical input bytes. each branch consumes the
26/// body to completion before its response body is collected.
27/// fan-out diff between two inners. Generic over the inner handle:
28/// `Diff<PipeHandle>` impls `Handler`; `Diff<ThreadLocalPipeHandle>`
29/// impls `ThreadLocalHandler`. Both branches must share handle type
30/// because the resulting future is one Send-ness.
31pub struct Diff<Inner = PipeHandle> {
32    pub left: Inner,
33    pub right: Inner,
34    pub replay_cap_bytes: usize,
35}
36
37impl<Inner> Diff<Inner> {
38    #[must_use]
39    pub fn new(left: Inner, right: Inner) -> Self {
40        Self {
41            left,
42            right,
43            replay_cap_bytes: DEFAULT_REPLAY_CAP_BYTES,
44        }
45    }
46
47    #[must_use]
48    pub fn with_replay_cap_bytes(mut self, cap: usize) -> Self {
49        self.replay_cap_bytes = cap;
50        self
51    }
52}
53
54impl<Inner> SendPipe for Diff<Inner>
55where
56    Inner: Handler + Clone,
57{
58    type In = Request<Bytes>;
59    type Out = Response<Bytes>;
60    type Err = ProximaError;
61
62    fn call(
63        &self,
64        request: Request<Bytes>,
65    ) -> impl Future<Output = Result<Response<Bytes>, ProximaError>> + Send {
66        let left = self.left.clone();
67        let right = self.right.clone();
68        let cap = self.replay_cap_bytes;
69        let Request {
70            method,
71            path,
72            query,
73            metadata,
74            payload,
75            stream,
76            context,
77        } = request;
78        async move {
79            let source = source_stream(payload, stream);
80            let (tee, primary_body) = Replay::wrap(source, cap);
81            let left_request = rebuild(&method, &path, &query, &metadata, &context, primary_body);
82            let right_body = tee.replay()?;
83            let right_request = rebuild(&method, &path, &query, &metadata, &context, right_body);
84
85            let (left_outcome, right_outcome) = futures::join!(
86                SendPipe::call(&left, left_request),
87                SendPipe::call(&right, right_request),
88            );
89            let left_snapshot = harvest("left", left_outcome).await;
90            let right_snapshot = harvest("right", right_outcome).await;
91            let report = build_report(&left_snapshot, &right_snapshot);
92            let metric = if report.identical {
93                COUNTER_IDENTICAL
94            } else {
95                COUNTER_DIVERGENT
96            };
97            context.telemetry.counter_inc(metric, &Labels::empty(), 1);
98            let body_bytes = serde_json::to_vec_pretty(&report)
99                .map_err(|err| ProximaError::Body(format!("diff serialize: {err}")))?;
100            Ok(Response::new(if report.identical { 200 } else { 409 })
101                .with_header("content-type", "application/json")
102                .with_body(Bytes::from(body_bytes)))
103        }
104    }
105}
106
107impl Pipe for Diff<ThreadLocalPipeHandle> {
108    type In = Request<Bytes>;
109    type Out = Response<Bytes>;
110    type Err = ProximaError;
111
112    fn call(
113        &self,
114        request: Request<Bytes>,
115    ) -> impl Future<Output = Result<Response<Bytes>, ProximaError>> {
116        let left = self.left.clone();
117        let right = self.right.clone();
118        let cap = self.replay_cap_bytes;
119        let Request {
120            method,
121            path,
122            query,
123            metadata,
124            payload,
125            stream,
126            context,
127        } = request;
128        async move {
129            let source = source_stream(payload, stream);
130            let (tee, primary_body) = Replay::wrap(source, cap);
131            let left_request = rebuild(&method, &path, &query, &metadata, &context, primary_body);
132            let right_body = tee.replay()?;
133            let right_request = rebuild(&method, &path, &query, &metadata, &context, right_body);
134
135            let (left_outcome, right_outcome) = futures::join!(
136                Pipe::call(&left, left_request),
137                Pipe::call(&right, right_request),
138            );
139            let left_snapshot = harvest("left", left_outcome).await;
140            let right_snapshot = harvest("right", right_outcome).await;
141            let report = build_report(&left_snapshot, &right_snapshot);
142            let metric = if report.identical {
143                COUNTER_IDENTICAL
144            } else {
145                COUNTER_DIVERGENT
146            };
147            context.telemetry.counter_inc(metric, &Labels::empty(), 1);
148            let body_bytes = serde_json::to_vec_pretty(&report)
149                .map_err(|err| ProximaError::Body(format!("diff serialize: {err}")))?;
150            Ok(Response::new(if report.identical { 200 } else { 409 })
151                .with_header("content-type", "application/json")
152                .with_body(Bytes::from(body_bytes)))
153        }
154    }
155}
156
157#[derive(Debug)]
158enum BranchSnapshot {
159    Ok { status: u16, body: Bytes },
160    Err { message: String },
161}
162
163async fn harvest(
164    _label: &'static str,
165    outcome: Result<Response<Bytes>, ProximaError>,
166) -> BranchSnapshot {
167    match outcome {
168        Ok(response) => {
169            let status = response.status;
170            match response.collect_body().await {
171                Ok(body) => BranchSnapshot::Ok { status, body },
172                Err(err) => BranchSnapshot::Err {
173                    message: format!("body collect: {err}"),
174                },
175            }
176        }
177        Err(err) => BranchSnapshot::Err {
178            message: err.to_string(),
179        },
180    }
181}
182
183#[derive(Debug, serde::Serialize)]
184struct DiffReport {
185    identical: bool,
186    summary: String,
187    left: BranchReport,
188    right: BranchReport,
189    #[serde(skip_serializing_if = "Option::is_none")]
190    first_diff_offset: Option<usize>,
191}
192
193#[derive(Debug, serde::Serialize)]
194struct BranchReport {
195    outcome: &'static str,
196    #[serde(skip_serializing_if = "Option::is_none")]
197    status: Option<u16>,
198    #[serde(skip_serializing_if = "Option::is_none")]
199    body_len: Option<usize>,
200    #[serde(skip_serializing_if = "Option::is_none")]
201    error: Option<String>,
202}
203
204fn build_report(left: &BranchSnapshot, right: &BranchSnapshot) -> DiffReport {
205    match (left, right) {
206        (
207            BranchSnapshot::Ok {
208                status: left_status,
209                body: left_body,
210            },
211            BranchSnapshot::Ok {
212                status: right_status,
213                body: right_body,
214            },
215        ) => {
216            if left_status == right_status && left_body == right_body {
217                return DiffReport {
218                    identical: true,
219                    summary: "byte-identical".into(),
220                    left: branch_ok(*left_status, left_body.len()),
221                    right: branch_ok(*right_status, right_body.len()),
222                    first_diff_offset: None,
223                };
224            }
225            let mut summary_parts: Vec<String> = Vec::new();
226            if left_status != right_status {
227                summary_parts.push(format!("status diverged ({left_status} vs {right_status})"));
228            }
229            let first_diff = if left_body != right_body {
230                summary_parts.push(format!(
231                    "body diverged ({} vs {} bytes)",
232                    left_body.len(),
233                    right_body.len()
234                ));
235                Some(first_byte_difference(left_body, right_body))
236            } else {
237                None
238            };
239            DiffReport {
240                identical: false,
241                summary: summary_parts.join(", "),
242                left: branch_ok(*left_status, left_body.len()),
243                right: branch_ok(*right_status, right_body.len()),
244                first_diff_offset: first_diff,
245            }
246        }
247        (BranchSnapshot::Err { message }, BranchSnapshot::Ok { .. }) => DiffReport {
248            identical: false,
249            summary: "left errored, right ok".into(),
250            left: branch_err(message),
251            right: branch_ok_from(right),
252            first_diff_offset: None,
253        },
254        (BranchSnapshot::Ok { .. }, BranchSnapshot::Err { message }) => DiffReport {
255            identical: false,
256            summary: "right errored, left ok".into(),
257            left: branch_ok_from(left),
258            right: branch_err(message),
259            first_diff_offset: None,
260        },
261        (
262            BranchSnapshot::Err {
263                message: left_message,
264            },
265            BranchSnapshot::Err {
266                message: right_message,
267            },
268        ) => DiffReport {
269            identical: left_message == right_message,
270            summary: if left_message == right_message {
271                "both errored identically".into()
272            } else {
273                format!("errors diverged: \"{left_message}\" vs \"{right_message}\"")
274            },
275            left: branch_err(left_message),
276            right: branch_err(right_message),
277            first_diff_offset: None,
278        },
279    }
280}
281
282fn first_byte_difference(left: &[u8], right: &[u8]) -> usize {
283    left.iter()
284        .zip(right.iter())
285        .take_while(|(left_byte, right_byte)| left_byte == right_byte)
286        .count()
287}
288
289fn branch_ok(status: u16, body_len: usize) -> BranchReport {
290    BranchReport {
291        outcome: "ok",
292        status: Some(status),
293        body_len: Some(body_len),
294        error: None,
295    }
296}
297
298fn branch_ok_from(snapshot: &BranchSnapshot) -> BranchReport {
299    match snapshot {
300        BranchSnapshot::Ok { status, body } => branch_ok(*status, body.len()),
301        BranchSnapshot::Err { message } => branch_err(message),
302    }
303}
304
305fn branch_err(message: &str) -> BranchReport {
306    BranchReport {
307        outcome: "error",
308        status: None,
309        body_len: None,
310        error: Some(message.to_string()),
311    }
312}
313
314fn source_stream(body: Bytes, stream: Option<RequestStream>) -> ChunkStream {
315    match stream {
316        Some(stream) => stream.into_chunk_stream(),
317        None => Box::pin(futures::stream::once(async move { Ok(body) })),
318    }
319}
320
321fn rebuild(
322    method: &Method,
323    path: &Bytes,
324    query: &crate::pipe::header_list::HeaderList,
325    metadata: &crate::pipe::header_list::HeaderList,
326    context: &crate::pipe::request::RequestContext,
327    body: ChunkStream,
328) -> Request<Bytes> {
329    Request {
330        method: method.clone(),
331        path: Bytes::clone(path),
332        query: query.clone(),
333        metadata: metadata.clone(),
334        payload: Bytes::new(),
335        stream: Some(RequestStream::from_chunk_stream(body)),
336        context: context.clone(),
337    }
338}
339
340/// helper that wraps two PipeHandles into a Diff Pipe handle.
341/// preferred entry point until config-driven dual-inner is supported.
342#[must_use]
343pub fn diff_handle(left: PipeHandle, right: PipeHandle) -> PipeHandle {
344    into_handle(Diff::new(left, right))
345}
346
347#[cfg(test)]
348#[allow(clippy::unwrap_used, clippy::expect_used)]
349mod tests {
350    use super::*;
351    use crate::pipe::telemetry_surface::Telemetry;
352    use std::collections::HashMap;
353    use std::sync::Mutex;
354
355    #[derive(Default)]
356    struct CounterRecorder {
357        counters: Mutex<HashMap<String, u64>>,
358    }
359
360    impl Telemetry for CounterRecorder {
361        fn counter_inc(&self, metric: &str, _labels: &Labels, by: u64) {
362            *self
363                .counters
364                .lock()
365                .unwrap()
366                .entry(metric.to_string())
367                .or_insert(0) += by;
368        }
369        fn gauge_set(&self, _: &str, _: &Labels, _: i64) {}
370        fn histogram_record(&self, _: &str, _: &Labels, _: f64) {}
371    }
372
373    impl CounterRecorder {
374        fn counter(&self, metric: &str) -> Option<u64> {
375            self.counters.lock().unwrap().get(metric).copied()
376        }
377    }
378
379    struct ConstantPipe {
380        status: u16,
381        body: &'static [u8],
382    }
383
384    impl SendPipe for ConstantPipe {
385        type In = Request<Bytes>;
386        type Out = Response<Bytes>;
387        type Err = ProximaError;
388
389        fn call(
390            &self,
391            _request: Request<Bytes>,
392        ) -> impl Future<Output = Result<Response<Bytes>, ProximaError>> + Send {
393            let status = self.status;
394            let body = self.body;
395            async move { Ok(Response::new(status).with_body(Bytes::from_static(body))) }
396        }
397    }
398
399    fn fresh_request() -> Request<Bytes> {
400        Request::builder()
401            .method("POST")
402            .path("/x")
403            .body("input")
404            .build()
405            .expect("builder")
406    }
407
408    #[proxima::test]
409    async fn identical_branches_report_byte_identical() {
410        let diff = Diff::new(
411            into_handle(ConstantPipe {
412                status: 200,
413                body: b"hello",
414            }),
415            into_handle(ConstantPipe {
416                status: 200,
417                body: b"hello",
418            }),
419        );
420        let response = SendPipe::call(&diff, fresh_request()).await.expect("call");
421        assert_eq!(response.status, 200);
422        let body = response.collect_body().await.expect("body");
423        let report: serde_json::Value = serde_json::from_slice(&body).expect("parse diff report");
424        assert_eq!(report["identical"], serde_json::Value::Bool(true));
425    }
426
427    #[proxima::test]
428    async fn body_divergence_reported_with_first_diff_offset() {
429        let diff = Diff::new(
430            into_handle(ConstantPipe {
431                status: 200,
432                body: b"hello world",
433            }),
434            into_handle(ConstantPipe {
435                status: 200,
436                body: b"hello there",
437            }),
438        );
439        let response = SendPipe::call(&diff, fresh_request()).await.expect("call");
440        assert_eq!(response.status, 409, "diverged → 409 conflict");
441        let body = response.collect_body().await.expect("body");
442        let report: serde_json::Value = serde_json::from_slice(&body).expect("parse diff report");
443        assert_eq!(report["identical"], serde_json::Value::Bool(false));
444        assert_eq!(report["first_diff_offset"], serde_json::json!(6));
445    }
446
447    #[proxima::test]
448    async fn status_divergence_surfaces_in_summary() {
449        let diff = Diff::new(
450            into_handle(ConstantPipe {
451                status: 200,
452                body: b"x",
453            }),
454            into_handle(ConstantPipe {
455                status: 500,
456                body: b"x",
457            }),
458        );
459        let response = SendPipe::call(&diff, fresh_request()).await.expect("call");
460        assert_eq!(response.status, 409);
461        let body = response.collect_body().await.expect("body");
462        let report: serde_json::Value = serde_json::from_slice(&body).expect("parse diff report");
463        let summary = report["summary"].as_str().expect("summary");
464        assert!(
465            summary.contains("200"),
466            "summary should mention left status: {summary}"
467        );
468        assert!(
469            summary.contains("500"),
470            "summary should mention right status: {summary}"
471        );
472    }
473
474    #[proxima::test]
475    async fn identical_call_emits_identical_counter() {
476        let metrics = std::sync::Arc::new(CounterRecorder::default());
477        let diff = Diff::new(
478            into_handle(ConstantPipe {
479                status: 200,
480                body: b"hello",
481            }),
482            into_handle(ConstantPipe {
483                status: 200,
484                body: b"hello",
485            }),
486        );
487        let request = Request::builder()
488            .method("POST")
489            .path("/x")
490            .body("input")
491            .telemetry(metrics.clone())
492            .build()
493            .expect("builder");
494        let _response = SendPipe::call(&diff, request).await.expect("call");
495        assert_eq!(metrics.counter(COUNTER_IDENTICAL), Some(1));
496        assert!(metrics.counter(COUNTER_DIVERGENT).is_none());
497    }
498
499    #[proxima::test]
500    async fn divergent_call_emits_divergent_counter() {
501        let metrics = std::sync::Arc::new(CounterRecorder::default());
502        let diff = Diff::new(
503            into_handle(ConstantPipe {
504                status: 200,
505                body: b"left",
506            }),
507            into_handle(ConstantPipe {
508                status: 200,
509                body: b"right",
510            }),
511        );
512        let request = Request::builder()
513            .method("POST")
514            .path("/x")
515            .body("input")
516            .telemetry(metrics.clone())
517            .build()
518            .expect("builder");
519        let _response = SendPipe::call(&diff, request).await.expect("call");
520        assert_eq!(metrics.counter(COUNTER_DIVERGENT), Some(1));
521        assert!(metrics.counter(COUNTER_IDENTICAL).is_none());
522    }
523}