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
19pub 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#[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}