use faucet_core::io::CallContext;
use faucet_core::serde_json::Value;
use faucet_core::traits::{Sink, Source};
use faucet_core::{CancellationToken, FaucetError};
use futures::StreamExt;
use std::collections::HashMap;
use std::future::Future;
use std::time::Duration;
use tokio::time::Instant;
pub const CALL_DEADLINE: Duration = Duration::from_millis(300);
pub const CALL_BOUND: Duration = Duration::from_secs(5);
async fn ends_within<F: Future<Output = Result<(), FaucetError>>>(what: &str, f: F) -> FaucetError {
let started = Instant::now();
let outcome = f.await;
let took = started.elapsed();
assert!(
took < CALL_BOUND,
"{what}: the call took {took:?}; it must end within {CALL_BOUND:?} of the deadline / cancel"
);
match outcome {
Err(e) => e,
Ok(()) => panic!("{what}: the call against a hung backend succeeded"),
}
}
#[allow(clippy::disallowed_methods)] async fn cancel_after(token: CancellationToken) {
tokio::time::sleep(CALL_DEADLINE).await;
token.cancel();
}
async fn drain(source: &dyn Source) -> Result<(), FaucetError> {
let ctx = HashMap::new();
let mut pages = source.stream_pages(&ctx, 0);
while let Some(page) = pages.next().await {
page?;
}
Ok(())
}
pub async fn assert_source_call_deadline<S, F>(make: F)
where
S: Source,
F: Fn() -> S,
{
let source = make();
source.set_call_context(CallContext::new().with_deadline(Instant::now() + CALL_DEADLINE));
let err = ends_within("source read with a deadline", drain(&source)).await;
assert_timeout_like(&err, "source read with a deadline");
let source = make();
let token = CancellationToken::new();
source.set_call_context(CallContext::new().with_cancel(token.clone()));
let (outcome, ()) = futures::future::join(
ends_within("source read after cancel", drain(&source)),
cancel_after(token),
)
.await;
assert_cancel_like(&outcome, "source read after cancel");
}
pub async fn assert_sink_call_deadline<S, F>(make: F, records: &[Value])
where
S: Sink,
F: Fn() -> S,
{
let write = |sink: S| async move {
sink.write_batch(records).await?;
sink.flush().await
};
let sink = make();
sink.set_call_context(CallContext::new().with_deadline(Instant::now() + CALL_DEADLINE));
let err = ends_within("sink write with a deadline", write(sink)).await;
assert_timeout_like(&err, "sink write with a deadline");
let sink = make();
let token = CancellationToken::new();
sink.set_call_context(CallContext::new().with_cancel(token.clone()));
let (outcome, ()) = futures::future::join(
ends_within("sink write after cancel", write(sink)),
cancel_after(token),
)
.await;
assert_cancel_like(&outcome, "sink write after cancel");
}
fn metered_recorder(
side: faucet_core::observability::RoundtripSide,
connector: &'static str,
) -> (
std::sync::Arc<faucet_core::observability::RoundtripRecorder>,
std::sync::Arc<faucet_core::UsageMeter>,
) {
let meter = std::sync::Arc::new(faucet_core::UsageMeter::new());
let recorder =
faucet_core::observability::RoundtripRecorder::new(side, "conformance", "", connector)
.with_meter(std::sync::Arc::clone(&meter));
(std::sync::Arc::new(recorder), meter)
}
pub async fn assert_source_records_roundtrips<S: Source>(source: &S) {
let (recorder, meter) = metered_recorder(
faucet_core::observability::RoundtripSide::Source,
source.connector_name(),
);
source.set_roundtrip_recorder(recorder);
drain(source)
.await
.expect("the source reads from its live backend");
let n = meter.snapshot().roundtrips(faucet_core::UsageSide::Source);
assert!(
n > 0,
"the installed recorder counted no source round trips"
);
}
pub async fn assert_sink_records_roundtrips<S: Sink>(sink: &S, records: &[Value]) {
let (recorder, meter) = metered_recorder(
faucet_core::observability::RoundtripSide::Sink,
sink.connector_name(),
);
sink.set_roundtrip_recorder(recorder);
sink.write_batch(records)
.await
.expect("the sink writes to its live backend");
sink.flush().await.expect("the sink flushes");
let n = meter.snapshot().roundtrips(faucet_core::UsageSide::Sink);
assert!(n > 0, "the installed recorder counted no sink round trips");
}
fn assert_timeout_like(err: &FaucetError, what: &str) {
let ok = matches!(err, FaucetError::Timeout { .. }) || err.to_string().contains("timed out");
assert!(ok, "{what}: expected a timeout, got {err:?}");
}
fn assert_cancel_like(err: &FaucetError, what: &str) {
let ok = matches!(err, FaucetError::Cancelled { .. }) || err.to_string().contains("cancelled");
assert!(ok, "{what}: expected a cancellation, got {err:?}");
}
#[cfg(test)]
#[allow(clippy::disallowed_methods)] mod tests {
use super::*;
use faucet_core::async_trait;
#[derive(Default)]
struct Hung {
slot: faucet_core::CallSlot,
honour: bool,
}
impl Hung {
async fn call(&self) -> Result<(), FaucetError> {
if self.honour {
self.slot.call("call", std::future::pending).await
} else {
tokio::time::sleep(Duration::from_secs(30)).await;
Ok(())
}
}
}
#[async_trait]
impl Source for Hung {
async fn fetch_with_context(
&self,
_ctx: &HashMap<String, Value>,
) -> Result<Vec<Value>, FaucetError> {
self.call().await?;
Ok(vec![])
}
fn set_call_context(&self, ctx: CallContext) {
self.slot.install(ctx);
}
}
#[async_trait]
impl Sink for Hung {
async fn write_batch(&self, _records: &[Value]) -> Result<usize, FaucetError> {
self.call().await?;
Ok(0)
}
fn set_call_context(&self, ctx: CallContext) {
self.slot.install(ctx);
}
}
#[derive(Default)]
struct Talks {
slot: faucet_core::CallSlot,
forward: bool,
}
impl Talks {
async fn call(&self) -> Result<(), FaucetError> {
self.slot.call("call", || async { Ok(()) }).await
}
fn install(&self, r: std::sync::Arc<faucet_core::observability::RoundtripRecorder>) {
if self.forward {
self.slot.install_recorder(r);
}
}
}
#[async_trait]
impl Source for Talks {
async fn fetch_with_context(
&self,
_ctx: &HashMap<String, Value>,
) -> Result<Vec<Value>, FaucetError> {
self.call().await?;
Ok(vec![])
}
fn set_roundtrip_recorder(
&self,
r: std::sync::Arc<faucet_core::observability::RoundtripRecorder>,
) {
self.install(r);
}
}
#[async_trait]
impl Sink for Talks {
async fn write_batch(&self, records: &[Value]) -> Result<usize, FaucetError> {
self.call().await?;
Ok(records.len())
}
fn set_roundtrip_recorder(
&self,
r: std::sync::Arc<faucet_core::observability::RoundtripRecorder>,
) {
self.install(r);
}
}
#[derive(Default)]
struct FlushHung(faucet_core::CallSlot);
#[async_trait]
impl Sink for FlushHung {
async fn write_batch(&self, records: &[Value]) -> Result<usize, FaucetError> {
Ok(records.len())
}
async fn flush(&self) -> Result<(), FaucetError> {
self.0.call("flush", std::future::pending).await
}
fn set_call_context(&self, ctx: CallContext) {
self.0.install(ctx);
}
}
#[tokio::test]
async fn a_sink_hanging_in_flush_is_bounded_too() {
assert_sink_call_deadline(FlushHung::default, &[faucet_core::json!({"a": 1})]).await;
}
#[tokio::test]
async fn a_connector_counting_through_its_recorder_passes() {
let t = Talks {
forward: true,
..Talks::default()
};
assert_source_records_roundtrips(&t).await;
let t = Talks {
forward: true,
..Talks::default()
};
assert_sink_records_roundtrips(&t, &[faucet_core::json!({"a": 1})]).await;
}
#[tokio::test]
#[should_panic(expected = "counted no source round trips")]
async fn a_source_dropping_its_recorder_fails() {
assert_source_records_roundtrips(&Talks::default()).await;
}
#[tokio::test]
#[should_panic(expected = "counted no sink round trips")]
async fn a_sink_dropping_its_recorder_fails() {
assert_sink_records_roundtrips(&Talks::default(), &[]).await;
}
fn honouring() -> Hung {
Hung {
honour: true,
..Hung::default()
}
}
#[tokio::test]
async fn a_connector_on_the_call_slot_passes() {
assert_source_call_deadline(honouring).await;
assert_sink_call_deadline(honouring, &[faucet_core::json!({"a": 1})]).await;
}
#[tokio::test(start_paused = true)]
#[should_panic(expected = "must end within")]
async fn a_connector_that_ignores_the_context_fails() {
assert_source_call_deadline(Hung::default).await;
}
#[test]
#[should_panic(expected = "expected a timeout")]
fn a_non_timeout_error_is_refused() {
assert_timeout_like(&FaucetError::Source("x".into()), "w");
}
#[test]
#[should_panic(expected = "expected a cancellation")]
fn a_non_cancel_error_is_refused() {
assert_cancel_like(&FaucetError::Source("x".into()), "w");
}
#[tokio::test]
#[should_panic(expected = "succeeded")]
async fn a_call_that_succeeds_is_refused() {
ends_within("w", async { Ok(()) }).await;
}
}