pub mod client;
pub mod consumer;
pub mod listener;
#[cfg(test)]
mod simulated_consumer;
pub mod stream;
pub mod types;
pub mod upload;
pub use nominal_api as api;
pub mod prelude {
pub use conjure_object::BearerToken;
pub use conjure_object::ResourceIdentifier;
pub use nominal_api::tonic::google::protobuf::Timestamp;
pub use nominal_api::tonic::io::nominal::scout::api::proto::array_points::ArrayType;
pub use nominal_api::tonic::io::nominal::scout::api::proto::points::PointsType;
pub use nominal_api::tonic::io::nominal::scout::api::proto::ArrayPoints;
pub use nominal_api::tonic::io::nominal::scout::api::proto::DoubleArrayPoint;
pub use nominal_api::tonic::io::nominal::scout::api::proto::DoubleArrayPoints;
pub use nominal_api::tonic::io::nominal::scout::api::proto::DoublePoint;
pub use nominal_api::tonic::io::nominal::scout::api::proto::DoublePoints;
pub use nominal_api::tonic::io::nominal::scout::api::proto::IntegerPoint;
pub use nominal_api::tonic::io::nominal::scout::api::proto::IntegerPoints;
pub use nominal_api::tonic::io::nominal::scout::api::proto::StringArrayPoint;
pub use nominal_api::tonic::io::nominal::scout::api::proto::StringArrayPoints;
pub use nominal_api::tonic::io::nominal::scout::api::proto::StringPoint;
pub use nominal_api::tonic::io::nominal::scout::api::proto::StringPoints;
pub use nominal_api::tonic::io::nominal::scout::api::proto::StructPoint;
pub use nominal_api::tonic::io::nominal::scout::api::proto::StructPoints;
pub use nominal_api::tonic::io::nominal::scout::api::proto::Uint64Point;
pub use nominal_api::tonic::io::nominal::scout::api::proto::Uint64Points;
pub use nominal_api::tonic::io::nominal::scout::api::proto::WriteRequest;
pub use nominal_api::tonic::io::nominal::scout::api::proto::WriteRequestNominal;
pub use crate::consumer::NominalCoreConsumer;
pub use crate::stream::NominalDatasetStream;
#[expect(deprecated)]
pub use crate::stream::NominalDatasourceStream;
pub use crate::stream::NominalStreamOpts;
pub use crate::types::AuthProvider;
pub use crate::types::ChannelDescriptor;
pub use crate::types::IntoTimestamp;
}
#[cfg(test)]
mod tests {
use std::collections::HashMap;
use std::sync::Arc;
use std::sync::Mutex;
use std::thread;
use std::time::Duration;
use std::time::UNIX_EPOCH;
use nominal_api::tonic::io::nominal::scout::api::proto::array_points::ArrayType;
use nominal_api::tonic::io::nominal::scout::api::proto::ArrayPoints;
use nominal_api::tonic::io::nominal::scout::api::proto::IntegerPoint;
use nominal_api::tonic::io::nominal::scout::api::proto::Points;
use crate::client::PRODUCTION_API_URL;
use crate::consumer::ConsumerResult;
use crate::consumer::RequestConsumerWithFallback;
use crate::consumer::WriteRequestConsumer;
use crate::prelude::*;
use crate::simulated_consumer::SimulatedNetworkConfig;
use crate::simulated_consumer::SimulatedNetworkConsumer;
use crate::simulated_consumer::SimulatedNetworkFailure;
use crate::simulated_consumer::SimulatedRetryPolicy;
#[derive(Debug)]
struct TestDatasourceStream {
requests: Mutex<Vec<WriteRequestNominal>>,
}
impl WriteRequestConsumer for Arc<TestDatasourceStream> {
fn consume(&self, request: &WriteRequestNominal) -> ConsumerResult<()> {
self.requests.lock().unwrap().push(request.clone());
Ok(())
}
}
fn create_test_stream() -> (Arc<TestDatasourceStream>, NominalDatasetStream) {
let test_consumer = Arc::new(TestDatasourceStream {
requests: Mutex::new(vec![]),
});
let stream = create_stream_with_consumer(test_consumer.clone(), 1000);
(test_consumer, stream)
}
fn create_stream_with_consumer<C>(
consumer: C,
max_points_per_record: usize,
) -> NominalDatasetStream
where
C: WriteRequestConsumer + 'static,
{
let stream = NominalDatasetStream::new_with_consumer(
consumer,
NominalStreamOpts {
max_points_per_record,
max_request_delay: Duration::from_millis(100),
max_buffered_requests: 2,
request_dispatcher_tasks: 4,
base_api_url: PRODUCTION_API_URL.to_string(),
},
);
stream
}
fn create_stream_with_consumer_and_options<C>(
consumer: C,
opts: NominalStreamOpts,
) -> NominalDatasetStream
where
C: WriteRequestConsumer + 'static,
{
NominalDatasetStream::new_with_consumer(consumer, opts)
}
fn total_double_points(requests: &[WriteRequestNominal], channel_name: &str) -> usize {
requests
.iter()
.flat_map(|request| request.series.iter())
.filter(|series| {
series
.channel
.as_ref()
.is_some_and(|channel| channel.name == channel_name)
})
.map(|series| {
let Some(Points {
points_type: Some(PointsType::DoublePoints(points)),
}) = series.points.as_ref()
else {
return 0;
};
points.points.len()
})
.sum()
}
fn point_counts_by_channel(
requests: &[WriteRequestNominal],
) -> HashMap<String, (&'static str, usize)> {
let mut counts = HashMap::new();
for series in requests.iter().flat_map(|request| request.series.iter()) {
let channel_name = series
.channel
.as_ref()
.expect("series should have a channel")
.name
.clone();
let points_type = series
.points
.as_ref()
.and_then(|points| points.points_type.as_ref())
.expect("series should have points");
let (kind, point_count) = match points_type {
PointsType::DoublePoints(points) => ("double", points.points.len()),
PointsType::StringPoints(points) => ("string", points.points.len()),
PointsType::IntegerPoints(points) => ("int", points.points.len()),
PointsType::Uint64Points(points) => ("uint64", points.points.len()),
PointsType::StructPoints(points) => ("struct", points.points.len()),
PointsType::ArrayPoints(ArrayPoints {
array_type: Some(ArrayType::DoubleArrayPoints(points)),
}) => ("double_array", points.points.len()),
PointsType::ArrayPoints(ArrayPoints {
array_type: Some(ArrayType::StringArrayPoints(points)),
}) => ("string_array", points.points.len()),
PointsType::ArrayPoints(ArrayPoints { array_type: None }) => ("array", 0),
};
let entry = counts.entry(channel_name).or_insert((kind, 0));
assert_eq!(entry.0, kind, "mismatched point type for channel");
entry.1 += point_count;
}
counts
}
#[test_log::test]
fn test_stream() {
let (test_consumer, stream) = create_test_stream();
for batch in 0..5 {
let mut points = Vec::new();
for i in 0..1000 {
let start_time = UNIX_EPOCH.elapsed().unwrap();
points.push(DoublePoint {
timestamp: Some(Timestamp {
seconds: start_time.as_secs() as i64,
nanos: start_time.subsec_nanos() as i32 + i,
}),
value: (i % 50) as f64,
});
}
stream.enqueue(
&ChannelDescriptor::with_tags("channel_1", [("batch_id", batch.to_string())]),
points,
);
}
drop(stream);
let requests = test_consumer.requests.lock().unwrap();
assert_eq!(requests.len(), 5);
let series = requests.first().unwrap().series.first().unwrap();
if let Some(PointsType::DoublePoints(points)) =
series.points.as_ref().unwrap().points_type.as_ref()
{
assert_eq!(points.points.len(), 1000);
} else {
panic!("unexpected data type");
}
}
#[test_log::test]
fn test_stream_types() {
let (test_consumer, stream) = create_test_stream();
for batch in 0..5 {
let mut doubles = Vec::new();
let mut strings = Vec::new();
let mut structs = Vec::new();
let mut ints = Vec::new();
let mut uints = Vec::new();
let mut double_arrays = Vec::new();
let mut string_arrays = Vec::new();
for i in 0..1000 {
let start_time = UNIX_EPOCH.elapsed().unwrap();
doubles.push(DoublePoint {
timestamp: Some(start_time.into_timestamp()),
value: (i % 50) as f64,
});
strings.push(StringPoint {
timestamp: Some(start_time.into_timestamp()),
value: format!("{}", i % 50),
});
structs.push(StructPoint {
timestamp: Some(start_time.into_timestamp()),
json_string: format!("{{\"v\":{}}}", i % 50),
});
ints.push(IntegerPoint {
timestamp: Some(start_time.into_timestamp()),
value: i % 50,
});
uints.push(Uint64Point {
timestamp: Some(start_time.into_timestamp()),
value: (i % 50) as u64,
});
double_arrays.push(DoubleArrayPoint {
timestamp: Some(start_time.into_timestamp()),
value: vec![(i % 50) as f64, ((i + 1) % 50) as f64],
});
string_arrays.push(StringArrayPoint {
timestamp: Some(start_time.into_timestamp()),
value: vec![format!("{}", i % 50)],
});
}
stream.enqueue(
&ChannelDescriptor::with_tags("double", [("batch_id", batch.to_string())]),
doubles,
);
stream.enqueue(
&ChannelDescriptor::with_tags("string", [("batch_id", batch.to_string())]),
strings,
);
stream.enqueue(
&ChannelDescriptor::with_tags("struct", [("batch_id", batch.to_string())]),
structs,
);
stream.enqueue(
&ChannelDescriptor::with_tags("int", [("batch_id", batch.to_string())]),
ints,
);
stream.enqueue(
&ChannelDescriptor::with_tags("uint64", [("batch_id", batch.to_string())]),
uints,
);
stream.enqueue(
&ChannelDescriptor::with_tags("double_array", [("batch_id", batch.to_string())]),
double_arrays,
);
stream.enqueue(
&ChannelDescriptor::with_tags("string_array", [("batch_id", batch.to_string())]),
string_arrays,
);
}
drop(stream);
let requests = test_consumer.requests.lock().unwrap();
assert_eq!(requests.len(), 35);
let r = requests
.iter()
.flat_map(|r| r.series.clone())
.map(|s| {
(
s.channel.unwrap().name,
s.points.unwrap().points_type.unwrap(),
)
})
.collect::<HashMap<_, _>>();
let PointsType::DoublePoints(dp) = r.get("double").unwrap() else {
panic!("invalid double points type");
};
let PointsType::IntegerPoints(ip) = r.get("int").unwrap() else {
panic!("invalid int points type");
};
let PointsType::Uint64Points(up) = r.get("uint64").unwrap() else {
panic!("invalid uint64 points type");
};
let PointsType::StringPoints(sp) = r.get("string").unwrap() else {
panic!("invalid string points type");
};
let PointsType::StructPoints(stp) = r.get("struct").unwrap() else {
panic!("invalid struct points type");
};
let PointsType::ArrayPoints(ArrayPoints {
array_type: Some(ArrayType::DoubleArrayPoints(dap)),
}) = r.get("double_array").unwrap()
else {
panic!("invalid double array points type");
};
let PointsType::ArrayPoints(ArrayPoints {
array_type: Some(ArrayType::StringArrayPoints(sap)),
}) = r.get("string_array").unwrap()
else {
panic!("invalid string array points type");
};
assert_eq!(dp.points.len(), 1000);
assert_eq!(sp.points.len(), 1000);
assert_eq!(ip.points.len(), 1000);
assert_eq!(up.points.len(), 1000);
assert_eq!(stp.points.len(), 1000);
assert_eq!(dap.points.len(), 1000);
assert_eq!(sap.points.len(), 1000);
}
#[test_log::test]
#[should_panic(expected = "mismatched types")]
fn test_mismatched_array_types_panics() {
let (_test_consumer, stream) = create_test_stream();
let stream = std::mem::ManuallyDrop::new(stream);
let cd = ChannelDescriptor::new("mixed_array");
let ts = UNIX_EPOCH.elapsed().unwrap().into_timestamp();
stream.enqueue(
&cd,
vec![DoubleArrayPoint {
timestamp: Some(ts),
value: vec![1.0, 2.0],
}],
);
stream.enqueue(
&cd,
vec![StringArrayPoint {
timestamp: Some(ts),
value: vec!["a".into()],
}],
);
}
#[test_log::test]
fn test_writer() {
let (test_consumer, stream) = create_test_stream();
let cd = ChannelDescriptor::new("channel_1");
let mut writer = stream.double_writer(cd);
for i in 0..5000 {
let start_time = UNIX_EPOCH.elapsed().unwrap();
let value = i % 50;
writer.push(start_time, value as f64);
}
drop(writer); drop(stream);
let requests = test_consumer.requests.lock().unwrap();
assert_eq!(requests.len(), 5);
let series = requests.first().unwrap().series.first().unwrap();
if let Some(PointsType::DoublePoints(points)) =
series.points.as_ref().unwrap().points_type.as_ref()
{
assert_eq!(points.points.len(), 1000);
} else {
panic!("unexpected data type");
}
}
#[test_log::test]
fn test_time_flush() {
let (test_consumer, stream) = create_test_stream();
let cd = ChannelDescriptor::new("channel_1");
let mut writer = stream.double_writer(cd);
writer.push(UNIX_EPOCH.elapsed().unwrap(), 1.0);
thread::sleep(Duration::from_millis(101));
writer.push(UNIX_EPOCH.elapsed().unwrap(), 2.0); thread::sleep(Duration::from_millis(101));
writer.push(UNIX_EPOCH.elapsed().unwrap(), 3.0);
drop(writer);
drop(stream);
let requests = test_consumer.requests.lock().unwrap();
dbg!(&requests);
assert_eq!(requests.len(), 2);
}
#[test_log::test]
fn simulated_network_retries_transient_failures_and_delivers_data() {
let test_consumer = Arc::new(TestDatasourceStream {
requests: Mutex::new(vec![]),
});
let simulated_network = SimulatedNetworkConsumer::new(
test_consumer.clone(),
SimulatedNetworkConfig::default()
.with_latency(Duration::from_micros(10), Duration::from_micros(10))
.with_bandwidth_limit(32 * 1024 * 1024)
.with_failure_pattern(SimulatedNetworkFailure::FailFirstAttemptsPerRequest {
attempts: 1,
})
.with_failure_pattern(SimulatedNetworkFailure::InitialOutageAttempts {
attempts: 1,
})
.with_retry_policy(
SimulatedRetryPolicy::new(2, Duration::from_micros(10))
.with_jitter(Duration::from_micros(10)),
),
);
let stats = simulated_network.stats();
let stream = create_stream_with_consumer(simulated_network, 4);
let cd = ChannelDescriptor::new("networked_double");
let mut writer = stream.double_writer(cd);
for i in 0..12 {
writer.push(UNIX_EPOCH.elapsed().unwrap(), i as f64);
}
drop(writer);
drop(stream);
let requests = test_consumer.requests.lock().unwrap();
assert_eq!(total_double_points(&requests, "networked_double"), 12);
let stats = stats.snapshot();
assert_eq!(stats.successful_requests as usize, requests.len());
assert_eq!(stats.simulated_failures, stats.successful_requests);
assert_eq!(stats.retries, stats.simulated_failures);
assert!(stats.attempts > stats.successful_requests);
assert!(stats.delivered_bytes > 0);
assert!(stats.simulated_sleep >= Duration::from_micros(stats.attempts * 10));
}
#[test_log::test]
fn simulated_network_all_requests_timeout_falls_back_under_backpressure() {
let primary_destination = Arc::new(TestDatasourceStream {
requests: Mutex::new(vec![]),
});
let fallback_destination = Arc::new(TestDatasourceStream {
requests: Mutex::new(vec![]),
});
let timeout = Duration::from_millis(5);
let simulated_network = SimulatedNetworkConsumer::new(
primary_destination.clone(),
SimulatedNetworkConfig::default()
.with_latency(timeout, Duration::ZERO)
.with_failure_pattern(SimulatedNetworkFailure::AllRequestsTimeout),
);
let stats = simulated_network.stats();
let stream = create_stream_with_consumer_and_options(
RequestConsumerWithFallback::new(simulated_network, fallback_destination.clone()),
NominalStreamOpts {
max_points_per_record: 2,
max_request_delay: Duration::from_millis(20),
max_buffered_requests: 1,
request_dispatcher_tasks: 1,
base_api_url: PRODUCTION_API_URL.to_string(),
},
);
let cd = ChannelDescriptor::new("timeout_double");
let mut writer = stream.double_writer(cd);
for i in 0..12 {
writer.push(UNIX_EPOCH.elapsed().unwrap(), i as f64);
}
drop(writer);
drop(stream);
let primary_requests = primary_destination.requests.lock().unwrap();
assert!(primary_requests.is_empty());
let fallback_requests = fallback_destination.requests.lock().unwrap();
assert_eq!(fallback_requests.len(), 6);
assert_eq!(
total_double_points(&fallback_requests, "timeout_double"),
12
);
let stats = stats.snapshot();
assert_eq!(stats.attempts, 6);
assert_eq!(stats.simulated_failures, 6);
assert_eq!(stats.successful_requests, 0);
assert_eq!(stats.retries, 0);
assert_eq!(stats.delivered_bytes, 0);
assert!(stats.simulated_sleep >= timeout * stats.attempts as u32);
}
#[test_log::test]
fn test_writer_types() {
let (test_consumer, stream) = create_test_stream();
let cd1 = ChannelDescriptor::new("double");
let cd2 = ChannelDescriptor::new("string");
let cd3 = ChannelDescriptor::new("int");
let cd4 = ChannelDescriptor::new("uint64");
let cd5 = ChannelDescriptor::new("struct");
let cd6 = ChannelDescriptor::new("double_array");
let cd7 = ChannelDescriptor::new("string_array");
let mut double_writer = stream.double_writer(cd1);
let mut string_writer = stream.string_writer(cd2);
let mut integer_writer = stream.integer_writer(cd3);
let mut uint64_writer = stream.uint64_writer(cd4);
let mut struct_writer = stream.struct_writer(cd5);
let mut double_array_writer = stream.double_array_writer(cd6);
let mut string_array_writer = stream.string_array_writer(cd7);
for i in 0..5000 {
let start_time = UNIX_EPOCH.elapsed().unwrap();
let value = i % 50;
double_writer.push(start_time, value as f64);
string_writer.push(start_time, format!("{}", value));
integer_writer.push(start_time, value);
uint64_writer.push(start_time, value as u64);
struct_writer.push(start_time, format!("{{\"v\":{}}}", value));
double_array_writer.push(start_time, vec![value as f64, (value + 1) as f64]);
string_array_writer.push(start_time, vec![format!("{}", value)]);
}
drop(double_writer);
drop(string_writer);
drop(integer_writer);
drop(uint64_writer);
drop(struct_writer);
drop(double_array_writer);
drop(string_array_writer);
drop(stream);
let requests = test_consumer.requests.lock().unwrap();
let counts = point_counts_by_channel(&requests);
assert_eq!(counts.get("double"), Some(&("double", 5000)));
assert_eq!(counts.get("string"), Some(&("string", 5000)));
assert_eq!(counts.get("int"), Some(&("int", 5000)));
assert_eq!(counts.get("uint64"), Some(&("uint64", 5000)));
assert_eq!(counts.get("struct"), Some(&("struct", 5000)));
assert_eq!(counts.get("double_array"), Some(&("double_array", 5000)));
assert_eq!(counts.get("string_array"), Some(&("string_array", 5000)));
}
}