Files
crank/crates/crank-core/src/streaming.rs
T
2026-04-06 09:57:28 +03:00

365 lines
12 KiB
Rust

use serde::{Deserialize, Serialize};
use thiserror::Error;
use crate::protocol::Protocol;
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ExecutionMode {
Unary,
Window,
Session,
AsyncJob,
}
impl ExecutionMode {
pub fn is_stateful(self) -> bool {
matches!(self, Self::Session | Self::AsyncJob)
}
pub fn requires_tool_family(self) -> bool {
self.is_stateful()
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum AggregationMode {
RawItems,
SummaryOnly,
SummaryPlusSamples,
Stats,
LatestState,
}
impl AggregationMode {
pub fn needs_items(self) -> bool {
matches!(self, Self::RawItems | Self::SummaryPlusSamples)
}
pub fn needs_summary(self) -> bool {
!matches!(self, Self::RawItems)
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum TransportBehavior {
RequestResponse,
ServerStream,
StatefulSession,
DeferredResult,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Default)]
pub struct ToolFamilyConfig {
#[serde(skip_serializing_if = "Option::is_none")]
pub start_tool_name: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub poll_tool_name: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub stop_tool_name: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub status_tool_name: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub result_tool_name: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub cancel_tool_name: Option<String>,
}
impl ToolFamilyConfig {
pub fn validate_for_mode(&self, mode: ExecutionMode) -> Result<(), StreamingConfigError> {
match mode {
ExecutionMode::Unary | ExecutionMode::Window => {
if self.start_tool_name.is_some()
|| self.poll_tool_name.is_some()
|| self.stop_tool_name.is_some()
|| self.status_tool_name.is_some()
|| self.result_tool_name.is_some()
|| self.cancel_tool_name.is_some()
{
return Err(StreamingConfigError::UnexpectedToolFamily(mode));
}
}
ExecutionMode::Session => {
if self.start_tool_name.is_none()
|| self.poll_tool_name.is_none()
|| self.stop_tool_name.is_none()
{
return Err(StreamingConfigError::MissingSessionToolNames);
}
}
ExecutionMode::AsyncJob => {
if self.start_tool_name.is_none()
|| self.status_tool_name.is_none()
|| self.result_tool_name.is_none()
|| self.cancel_tool_name.is_none()
{
return Err(StreamingConfigError::MissingAsyncJobToolNames);
}
}
}
Ok(())
}
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct StreamingConfig {
pub mode: ExecutionMode,
pub transport_behavior: TransportBehavior,
#[serde(skip_serializing_if = "Option::is_none")]
pub window_duration_ms: Option<u64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub poll_interval_ms: Option<u64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub upstream_timeout_ms: Option<u64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub idle_timeout_ms: Option<u64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub max_session_lifetime_ms: Option<u64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub max_items: Option<u32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub max_bytes: Option<u32>,
pub aggregation_mode: AggregationMode,
#[serde(skip_serializing_if = "Option::is_none")]
pub summary_path: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub items_path: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub cursor_path: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub status_path: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub done_path: Option<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub redacted_paths: Vec<String>,
#[serde(default)]
pub truncate_item_fields: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub max_field_length: Option<u32>,
#[serde(default)]
pub drop_duplicates: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub sampling_rate: Option<f64>,
#[serde(default)]
pub tool_family: ToolFamilyConfig,
}
impl StreamingConfig {
pub fn validate_common(&self) -> Result<(), StreamingConfigError> {
self.tool_family.validate_for_mode(self.mode)?;
if self.max_items.is_some_and(|value| value == 0) {
return Err(StreamingConfigError::InvalidMaxItems);
}
if self.max_bytes.is_some_and(|value| value == 0) {
return Err(StreamingConfigError::InvalidMaxBytes);
}
if self.max_field_length.is_some_and(|value| value == 0) {
return Err(StreamingConfigError::InvalidMaxFieldLength);
}
if self
.sampling_rate
.is_some_and(|value| value <= 0.0 || value > 1.0)
{
return Err(StreamingConfigError::InvalidSamplingRate);
}
match self.mode {
ExecutionMode::Unary => {
if self.window_duration_ms.is_some()
|| self.poll_interval_ms.is_some()
|| self.idle_timeout_ms.is_some()
|| self.max_session_lifetime_ms.is_some()
{
return Err(StreamingConfigError::UnexpectedStatefulLimits(
ExecutionMode::Unary,
));
}
}
ExecutionMode::Window => {
if self.window_duration_ms.is_none() {
return Err(StreamingConfigError::MissingWindowDuration);
}
}
ExecutionMode::Session => {
if self.poll_interval_ms.is_none() || self.max_session_lifetime_ms.is_none() {
return Err(StreamingConfigError::MissingSessionLimits);
}
}
ExecutionMode::AsyncJob => {}
}
Ok(())
}
pub fn validate_for_protocol(&self, protocol: Protocol) -> Result<(), StreamingConfigError> {
self.validate_common()?;
if !protocol.supports_execution_mode(self.mode) {
return Err(StreamingConfigError::UnsupportedExecutionMode {
protocol,
mode: self.mode,
});
}
if !protocol.supports_transport_behavior(self.transport_behavior) {
return Err(StreamingConfigError::UnsupportedTransportBehavior {
protocol,
behavior: self.transport_behavior,
});
}
Ok(())
}
}
#[derive(Clone, Debug, Error, PartialEq)]
pub enum StreamingConfigError {
#[error("window mode requires window_duration_ms")]
MissingWindowDuration,
#[error("session mode requires poll_interval_ms and max_session_lifetime_ms")]
MissingSessionLimits,
#[error("max_items must be greater than zero")]
InvalidMaxItems,
#[error("max_bytes must be greater than zero")]
InvalidMaxBytes,
#[error("max_field_length must be greater than zero")]
InvalidMaxFieldLength,
#[error("sampling_rate must be in range (0, 1]")]
InvalidSamplingRate,
#[error("{0:?} mode cannot use session/window-only limits")]
UnexpectedStatefulLimits(ExecutionMode),
#[error("{mode:?} is not supported for protocol {protocol:?}")]
UnsupportedExecutionMode {
protocol: Protocol,
mode: ExecutionMode,
},
#[error("{behavior:?} is not supported for protocol {protocol:?}")]
UnsupportedTransportBehavior {
protocol: Protocol,
behavior: TransportBehavior,
},
#[error("session mode requires start, poll and stop tool names")]
MissingSessionToolNames,
#[error("async_job mode requires start, status, result and cancel tool names")]
MissingAsyncJobToolNames,
#[error("{0:?} mode cannot define tool-family names")]
UnexpectedToolFamily(ExecutionMode),
}
#[cfg(test)]
mod tests {
use serde_json::json;
use super::{
AggregationMode, ExecutionMode, StreamingConfig, StreamingConfigError, ToolFamilyConfig,
TransportBehavior,
};
use crate::protocol::Protocol;
fn session_config() -> StreamingConfig {
StreamingConfig {
mode: ExecutionMode::Session,
transport_behavior: TransportBehavior::StatefulSession,
window_duration_ms: None,
poll_interval_ms: Some(1_000),
upstream_timeout_ms: Some(5_000),
idle_timeout_ms: Some(15_000),
max_session_lifetime_ms: Some(60_000),
max_items: Some(100),
max_bytes: Some(65_536),
aggregation_mode: AggregationMode::SummaryPlusSamples,
summary_path: Some("$.summary".to_owned()),
items_path: Some("$.items".to_owned()),
cursor_path: Some("$.cursor".to_owned()),
status_path: Some("$.status".to_owned()),
done_path: Some("$.done".to_owned()),
redacted_paths: vec!["$.items[*].token".to_owned()],
truncate_item_fields: true,
max_field_length: Some(256),
drop_duplicates: true,
sampling_rate: Some(0.5),
tool_family: ToolFamilyConfig {
start_tool_name: Some("logs_start".to_owned()),
poll_tool_name: Some("logs_poll".to_owned()),
stop_tool_name: Some("logs_stop".to_owned()),
status_tool_name: None,
result_tool_name: None,
cancel_tool_name: None,
},
}
}
#[test]
fn streaming_config_roundtrips_through_json() {
let config = session_config();
let value = serde_json::to_value(&config).unwrap();
let decoded: StreamingConfig = serde_json::from_value(value.clone()).unwrap();
assert_eq!(decoded, config);
assert_eq!(value["mode"], json!("session"));
assert_eq!(value["tool_family"]["start_tool_name"], json!("logs_start"));
}
#[test]
fn unary_mode_rejects_session_specific_fields() {
let config = StreamingConfig {
mode: ExecutionMode::Unary,
transport_behavior: TransportBehavior::RequestResponse,
window_duration_ms: None,
poll_interval_ms: Some(1_000),
upstream_timeout_ms: None,
idle_timeout_ms: None,
max_session_lifetime_ms: None,
max_items: None,
max_bytes: None,
aggregation_mode: AggregationMode::SummaryOnly,
summary_path: None,
items_path: None,
cursor_path: None,
status_path: None,
done_path: None,
redacted_paths: Vec::new(),
truncate_item_fields: false,
max_field_length: None,
drop_duplicates: false,
sampling_rate: None,
tool_family: ToolFamilyConfig::default(),
};
assert_eq!(
config.validate_common(),
Err(StreamingConfigError::UnexpectedStatefulLimits(
ExecutionMode::Unary
))
);
}
#[test]
fn protocol_validation_rejects_graphql_session() {
let config = session_config();
assert_eq!(
config.validate_for_protocol(Protocol::Graphql),
Err(StreamingConfigError::UnsupportedExecutionMode {
protocol: Protocol::Graphql,
mode: ExecutionMode::Session,
})
);
}
#[test]
fn protocol_validation_accepts_rest_session() {
let config = session_config();
assert!(config.validate_for_protocol(Protocol::Rest).is_ok());
}
}