use std::num::NonZeroUsize;
use anyhow::{Context, Result};
use metrics::counter;
use reqwest::header::{CONTENT_ENCODING, CONTENT_TYPE};
use tokio::{
sync::mpsc::{self, error::TrySendError},
task::JoinHandle,
time::{timeout_at, Instant},
};
use tracing::{error, info, warn};
use crate::{api::record::QuoteRecord, config::defaults};
#[derive(Clone, Debug)]
pub(crate) struct RecordEmitter {
sender: mpsc::Sender<QuoteRecord>,
}
impl RecordEmitter {
#[must_use]
pub(crate) fn new(capacity: NonZeroUsize) -> (Self, mpsc::Receiver<QuoteRecord>) {
let (sender, receiver) = mpsc::channel(capacity.get());
(Self { sender }, receiver)
}
pub(crate) fn emit(&self, record: QuoteRecord) {
match self.sender.try_send(record) {
Ok(()) => {}
Err(TrySendError::Full(_)) => record_dropped("queue_full", 1),
Err(TrySendError::Closed(_)) => record_dropped("sender_stopped", 1),
}
}
}
fn record_dropped(reason: &'static str, records: u64) {
counter!("quote_records_dropped_total", "reason" => reason).increment(records);
}
fn record_sent(records: u64) {
counter!("quote_records_sent_total").increment(records);
}
pub(crate) fn spawn_record_sender(collector_url: &str) -> Result<(RecordEmitter, JoinHandle<()>)> {
let collector = RecordCollector::new(collector_url)?;
info!(url = %collector.records_url, "emitting quote records");
let (emitter, receiver) = RecordEmitter::new(defaults::RECORD_QUEUE_CAPACITY);
Ok((emitter, tokio::spawn(drain_into(collector, receiver))))
}
async fn drain_into(collector: RecordCollector, mut receiver: mpsc::Receiver<QuoteRecord>) {
while let Some(first) = receiver.recv().await {
let deadline = Instant::now() + defaults::RECORD_FLUSH_INTERVAL;
let mut batch = Batch::default();
batch.push(&first);
while !batch.is_full() {
match timeout_at(deadline, receiver.recv()).await {
Ok(Some(record)) => batch.push(&record),
Ok(None) | Err(_) => break,
}
}
collector.post(batch).await;
}
}
#[derive(Default)]
struct Batch {
records: Vec<String>,
bytes: usize,
}
impl Batch {
fn push(&mut self, record: &QuoteRecord) {
match serde_json::to_string(record) {
Ok(json) => {
self.bytes += json.len() + 1; self.records.push(json);
}
Err(error) => {
error!(%error, "dropping a quote record that cannot be serialized");
record_dropped("encode_failed", 1);
}
}
}
fn is_full(&self) -> bool {
self.records.len() >= defaults::RECORD_BATCH_MAX_RECORDS ||
self.bytes >= defaults::RECORD_BATCH_MAX_BYTES
}
fn len(&self) -> usize {
self.records.len()
}
fn into_body(self) -> String {
format!(r#"{{"records":[{}]}}"#, self.records.join(","))
}
}
struct RecordCollector {
client: reqwest::Client,
records_url: String,
}
impl RecordCollector {
fn new(collector_url: &str) -> Result<Self> {
let records_url = format!("{}/v1/records", collector_url.trim_end_matches('/'));
let parsed = reqwest::Url::parse(&records_url)
.with_context(|| format!("collector URL is not a URL: {collector_url}"))?;
anyhow::ensure!(
matches!(parsed.scheme(), "http" | "https"),
"collector URL must be http or https: {collector_url}"
);
Ok(Self { client: reqwest::Client::new(), records_url })
}
async fn post(&self, batch: Batch) {
let records = batch.len() as u64;
if records == 0 {
return;
}
let body =
match zstd::encode_all(batch.into_body().as_bytes(), zstd::DEFAULT_COMPRESSION_LEVEL) {
Ok(body) => body,
Err(error) => {
error!(%error, records, "dropping a batch that could not be compressed");
record_dropped("encode_failed", records);
return;
}
};
let response = self
.client
.post(&self.records_url)
.header(CONTENT_TYPE, "application/json")
.header(CONTENT_ENCODING, "zstd")
.timeout(defaults::RECORD_POST_TIMEOUT)
.body(body)
.send()
.await;
match response {
Ok(response) if response.status().is_success() => record_sent(records),
Ok(response) => {
warn!(status = %response.status(), records, "collector refused a batch of records");
record_dropped("collector_rejected", records);
}
Err(error) if error.is_timeout() => {
warn!(%error, records, "collector did not answer in time");
record_dropped("collector_timeout", records);
}
Err(error) => {
warn!(%error, records, "could not reach the collector");
record_dropped("collector_unreachable", records);
}
}
}
}
#[cfg(test)]
mod tests {
use std::time::{Duration, Instant};
use actix_web::http::header::HeaderMap;
use fynd_core::{ExclusiveAccess, QuoteRequest, SolveError};
use fynd_rpc_types::{Bytes, Order, OrderSide};
use metrics_util::debugging::{DebugValue, DebuggingRecorder};
use num_bigint::BigUint;
use serde_json::Value;
use tycho_simulation::tycho_common::models::Chain;
use super::*;
use crate::api::{middleware::ClientInfo, record::RequestRecord};
fn queue_of(capacity: usize) -> NonZeroUsize {
NonZeroUsize::new(capacity).expect("a test never asks for an empty queue")
}
fn record(amount: u64) -> QuoteRecord {
let order = Order::new(
Bytes::from([0xAAu8; 20]),
Bytes::from([0xBBu8; 20]),
BigUint::from(amount),
OrderSide::Sell,
Bytes::from([0xCCu8; 20]),
);
let request: QuoteRequest = fynd_rpc_types::QuoteRequest::new(vec![order]).into();
QuoteRecord::build(
RequestRecord::capture(&request, ExclusiveAccess::Denied),
&Err(SolveError::QueueFull),
Chain::Ethereum,
ClientInfo::from_headers(&HeaderMap::new()),
None,
)
}
fn amount_of(record: &QuoteRecord) -> String {
let value = serde_json::to_value(record).unwrap();
value["request"]["orders"][0]["amount"]
.as_str()
.unwrap()
.to_string()
}
fn drops_by_reason(f: impl FnOnce()) -> Vec<(String, u64)> {
let recorder = DebuggingRecorder::new();
let snapshotter = recorder.snapshotter();
metrics::with_local_recorder(&recorder, f);
drops_of(&snapshotter.snapshot().into_vec())
}
type Recorded = (
metrics_util::CompositeKey,
Option<metrics::Unit>,
Option<metrics::SharedString>,
DebugValue,
);
fn drops_of(recorded: &[Recorded]) -> Vec<(String, u64)> {
recorded
.iter()
.filter(|(key, _, _, _)| key.key().name() == "quote_records_dropped_total")
.map(|(key, _, _, value)| {
let reason = key
.key()
.labels()
.find(|label| label.key() == "reason")
.expect("drop carries a reason")
.value()
.to_string();
let DebugValue::Counter(count) = value else { panic!("not a counter: {value:?}") };
(reason, *count)
})
.collect()
}
#[test]
fn test_emit_queues_record() {
let (emitter, mut receiver) = RecordEmitter::new(queue_of(4));
let drops = drops_by_reason(|| emitter.emit(record(1)));
assert!(drops.is_empty(), "nothing was dropped: {drops:?}");
assert_eq!(
amount_of(
&receiver
.try_recv()
.expect("record queued")
),
"1"
);
}
#[test]
fn test_emit_drops_incoming_record_when_queue_is_full() {
let (emitter, mut receiver) = RecordEmitter::new(queue_of(1));
emitter.emit(record(1));
let drops = drops_by_reason(|| emitter.emit(record(2)));
assert_eq!(drops, vec![("queue_full".to_string(), 1)]);
assert_eq!(
amount_of(
&receiver
.try_recv()
.expect("first record kept")
),
"1"
);
assert!(receiver.try_recv().is_err(), "the dropped record was not queued");
}
#[test]
fn test_emit_counts_drops_once_the_sending_task_is_gone() {
let (emitter, receiver) = RecordEmitter::new(queue_of(1));
drop(receiver);
let drops = drops_by_reason(|| emitter.emit(record(1)));
assert_eq!(drops, vec![("sender_stopped".to_string(), 1)]);
}
#[test]
fn test_emit_into_full_queue_returns_immediately() {
let (emitter, _receiver) = RecordEmitter::new(queue_of(1));
emitter.emit(record(1));
let records: Vec<QuoteRecord> = (0..1_000).map(record).collect();
let started = Instant::now();
for record in records {
emitter.emit(record);
}
assert!(started.elapsed() < Duration::from_millis(100), "took {:?}", started.elapsed());
}
async fn stub_collector(status: u16, delay: Duration) -> wiremock::MockServer {
let server = wiremock::MockServer::start().await;
wiremock::Mock::given(wiremock::matchers::method("POST"))
.and(wiremock::matchers::path("/v1/records"))
.respond_with(wiremock::ResponseTemplate::new(status).set_delay(delay))
.mount(&server)
.await;
server
}
async fn received_batch(server: &wiremock::MockServer) -> Value {
let requests = server
.received_requests()
.await
.expect("the stub records requests");
assert_eq!(requests.len(), 1, "expected exactly one POST, got {}", requests.len());
let request = &requests[0];
assert_eq!(request.headers["content-type"], "application/json");
assert_eq!(request.headers["content-encoding"], "zstd");
let body = zstd::decode_all(request.body.as_slice()).expect("body is zstd");
serde_json::from_slice(&body).expect("body is the collector's JSON")
}
async fn drain_to(server: &wiremock::MockServer, records: u64) -> Vec<Recorded> {
let collector = RecordCollector::new(&server.uri()).expect("the stub's URI is a URL");
let (emitter, receiver) = RecordEmitter::new(queue_of(16));
for amount in 1..=records {
emitter.emit(record(amount));
}
drop(emitter);
metrics_after(drain_into(collector, receiver)).await
}
async fn metrics_after(task: impl std::future::Future<Output = ()>) -> Vec<Recorded> {
let mut task = Box::pin(task);
let recorder = DebuggingRecorder::new();
let snapshotter = recorder.snapshotter();
std::future::poll_fn(|cx| {
metrics::with_local_recorder(&recorder, || task.as_mut().poll(cx))
})
.await;
snapshotter.snapshot().into_vec()
}
fn sent_of(recorded: &[Recorded]) -> u64 {
recorded
.iter()
.find(|(key, _, _, _)| key.key().name() == "quote_records_sent_total")
.map_or(0, |(_, _, _, value)| {
let DebugValue::Counter(count) = value else { panic!("not a counter: {value:?}") };
*count
})
}
#[tokio::test]
async fn test_collector_posts_queued_records_in_one_batch() {
let server = stub_collector(202, Duration::ZERO).await;
let recorded = drain_to(&server, 3).await;
let drops = drops_of(&recorded);
assert!(drops.is_empty(), "nothing was dropped: {drops:?}");
assert_eq!(sent_of(&recorded), 3, "every record in the batch counts as sent");
let batch = received_batch(&server).await;
let records = batch["records"]
.as_array()
.expect("the body carries a records array");
assert_eq!(records.len(), 3);
assert_eq!(records[0]["request"]["orders"][0]["amount"], "1");
assert_eq!(records[2]["request"]["orders"][0]["amount"], "3");
}
#[tokio::test]
async fn test_collector_counts_a_timeout() {
let server =
stub_collector(202, defaults::RECORD_POST_TIMEOUT + Duration::from_secs(1)).await;
let recorded = drain_to(&server, 2).await;
assert_eq!(drops_of(&recorded), vec![("collector_timeout".to_string(), 2)]);
assert_eq!(sent_of(&recorded), 0, "a batch that timed out is not counted as sent");
}
#[tokio::test]
async fn test_collector_counts_a_refusal() {
let server = stub_collector(400, Duration::ZERO).await;
let recorded = drain_to(&server, 2).await;
assert_eq!(drops_of(&recorded), vec![("collector_rejected".to_string(), 2)]);
assert_eq!(
server
.received_requests()
.await
.expect("the stub records requests")
.len(),
1,
"a refused batch is never retried"
);
}
#[tokio::test]
async fn test_collector_counts_an_unreachable_collector() {
let collector = RecordCollector::new("http://127.0.0.1:1").expect("a URL");
let (emitter, receiver) = RecordEmitter::new(queue_of(4));
emitter.emit(record(1));
drop(emitter);
let recorded = metrics_after(drain_into(collector, receiver)).await;
assert_eq!(drops_of(&recorded), vec![("collector_unreachable".to_string(), 1)]);
}
#[tokio::test]
async fn test_collector_rejects_a_url_that_is_not_one() {
assert!(spawn_record_sender("collector.internal:8080").is_err());
}
#[rstest::rstest]
#[case("http://collector.internal:8080")]
#[case("http://collector.internal:8080/")]
fn test_collector_url_names_the_records_endpoint(#[case] root: &str) {
let collector = RecordCollector::new(root).expect("a URL");
assert_eq!(collector.records_url, "http://collector.internal:8080/v1/records");
}
#[test]
fn test_batch_fills_at_the_record_cap() {
let mut batch = Batch::default();
let record = record(1);
for _ in 0..defaults::RECORD_BATCH_MAX_RECORDS - 1 {
batch.push(&record);
}
assert!(!batch.is_full(), "a batch under the cap has room");
batch.push(&record);
assert!(batch.is_full());
}
#[test]
fn test_batch_body_wraps_the_records() {
let mut batch = Batch::default();
batch.push(&record(1));
batch.push(&record(2));
let body: Value = serde_json::from_str(&batch.into_body()).expect("valid JSON");
assert_eq!(
body["records"]
.as_array()
.expect("a records array")
.len(),
2
);
}
}