rialo-api-types 0.8.0-alpha.0

API types for Rialo RPC endpoints
Documentation
// Copyright (c) Subzero Labs, Inc.
// SPDX-License-Identifier: Apache-2.0

//! Types for duty dispatch rate limiting RPC endpoints.
//!
//! These types support the admin RPC endpoints for monitoring and configuring
//! the REX duty dispatch rate limiting system on validator nodes.

use serde::{Deserialize, Serialize};

/// Runtime status of the duty dispatch rate limiting system.
///
/// This type is purpose-built for the admin RPC endpoint, providing operators
/// with visibility into the current rate limiting state to help tune
/// `max_concurrent` settings.
///
/// # Related Types
///
/// This type is distinct from other duty dispatch types in the codebase:
///
/// - **`DutyDispatchConfig`** (in `rialo-rex-types`): Static configuration
///   parameters (`max_concurrent`, `max_duties_per_commit`). This struct includes
///   `max_concurrent` from config but adds runtime state not available in
///   `DutyDispatchConfig`.
///
/// - **`DutyDispatchQueueStats`** (in `rialo-rex`): Queue health metrics
///   focused on duty age and expiry (`total_duties`, `expired_duties`,
///   `avg_age_rounds`). That type requires a `current_commit_index` parameter
///   to compute age-based metrics, while `DutyDispatchStatus` captures
///   instantaneous rate limiting state without commit context.
///
/// # Why a Separate Type?
///
/// We chose to create a new type rather than extend existing types because:
///
/// 1. **Different purposes**: `DutyDispatchQueueStats` answers "how healthy is
///    the queue over time?" while `DutyDispatchStatus` answers "can I dispatch
///    more requests right now?"
///
/// 2. **Different access patterns**: Stats requires commit index for age
///    calculations; status is instantaneous.
///
/// 3. **RPC serialization needs**: This type includes `Serialize`/`Deserialize`
///    for JSON API responses, which the internal stats type doesn't need.
///
/// # Example Response
///
/// ```json
/// {
///   "max_concurrent": 50,
///   "in_flight": 23,
///   "available_slots": 27,
///   "queue_size": 15
/// }
/// ```
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct DutyDispatchStatus {
    /// Maximum concurrent in-flight requests allowed.
    ///
    /// This is the configured limit that can be adjusted via RPC.
    /// Default is 50.
    pub max_concurrent: u32,

    /// Current number of in-flight TEE requests.
    ///
    /// These are requests that have been dispatched to the TEE but have not
    /// yet completed (either successfully or with an error).
    pub in_flight: u32,

    /// Number of available dispatch slots.
    ///
    /// Calculated as `max_concurrent - in_flight`. When this is 0, no more
    /// duties can be dispatched until some in-flight requests complete.
    pub available_slots: usize,

    /// Number of duties waiting in the main queue.
    ///
    /// This is the count of ASAP duties in the primary FIFO queue, not including
    /// deferred duties. A consistently growing queue size may indicate backpressure.
    pub queue_size: usize,
}

impl DutyDispatchStatus {
    /// Creates a new status instance.
    pub fn new(
        max_concurrent: u32,
        in_flight: u32,
        available_slots: usize,
        queue_size: usize,
    ) -> Self {
        Self {
            max_concurrent,
            in_flight,
            available_slots,
            queue_size,
        }
    }

    /// Returns true if the dispatch queue has available capacity.
    pub fn has_capacity(&self) -> bool {
        self.available_slots > 0
    }

    /// Returns the utilization ratio (in_flight / max_concurrent).
    ///
    /// Returns 0.0 if max_concurrent is 0 to avoid division by zero.
    pub fn utilization(&self) -> f64 {
        if self.max_concurrent == 0 {
            return 0.0;
        }
        self.in_flight as f64 / self.max_concurrent as f64
    }
}

/// Request to update the maximum concurrent in-flight requests.
///
/// This is used by the admin RPC endpoint to allow operators to tune
/// the rate limiting behavior based on their TEE capacity.
///
/// # Validation
///
/// - There is no upper limit enforced, but values above 1000 are unusual
///
/// # Example Request
///
/// ```json
/// {
///   "max_concurrent": 100
/// }
/// ```
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct SetMaxConcurrentRequest {
    #[serde(default)]
    pub version: u16,

    /// New maximum concurrent value.
    ///
    /// Setting this to a higher value allows more parallel TEE requests,
    /// which can improve throughput but may overwhelm the TEE if set too high.
    pub max_concurrent: u32,
}

impl SetMaxConcurrentRequest {
    /// Creates a new request with the specified max_concurrent value.
    pub fn new(max_concurrent: u32) -> Self {
        Self {
            version: 0,
            max_concurrent,
        }
    }

    /// Validates the request.
    ///
    /// Returns `Ok(())` if valid, or an error message if invalid.
    pub fn validate(&self) -> Result<(), &'static str> {
        Ok(())
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn test_duty_dispatch_status_serialization() {
        let status = DutyDispatchStatus::new(50, 23, 27, 15);
        let json = serde_json::to_string(&status).unwrap();
        let deserialized: DutyDispatchStatus = serde_json::from_str(&json).unwrap();
        assert_eq!(status, deserialized);
    }

    #[test]
    fn test_duty_dispatch_status_has_capacity() {
        let status_with_capacity = DutyDispatchStatus::new(50, 23, 27, 10);
        assert!(status_with_capacity.has_capacity());

        let status_full = DutyDispatchStatus::new(50, 50, 0, 10);
        assert!(!status_full.has_capacity());
    }

    #[test]
    fn test_duty_dispatch_status_utilization() {
        let status = DutyDispatchStatus::new(100, 50, 50, 10);
        assert!((status.utilization() - 0.5).abs() < f64::EPSILON);

        let status_empty = DutyDispatchStatus::new(100, 0, 100, 10);
        assert!((status_empty.utilization() - 0.0).abs() < f64::EPSILON);

        let status_full = DutyDispatchStatus::new(100, 100, 0, 10);
        assert!((status_full.utilization() - 1.0).abs() < f64::EPSILON);

        // Edge case: max_concurrent = 0 (shouldn't happen but handle gracefully)
        let status_zero = DutyDispatchStatus::new(0, 0, 0, 0);
        assert!((status_zero.utilization() - 0.0).abs() < f64::EPSILON);
    }

    #[test]
    fn test_set_max_concurrent_request_serialization() {
        let request = SetMaxConcurrentRequest::new(100);
        let json = serde_json::to_string(&request).unwrap();
        assert_eq!(json, r#"{"version":0,"max_concurrent":100}"#);

        let deserialized: SetMaxConcurrentRequest = serde_json::from_str(&json).unwrap();
        assert_eq!(request, deserialized);
    }

    #[test]
    fn test_set_max_concurrent_request_validation() {
        // Valid request
        let valid_request = SetMaxConcurrentRequest::new(50);
        assert!(valid_request.validate().is_ok());

        // Valid: zero
        let zero_request = SetMaxConcurrentRequest::new(0);
        assert!(zero_request.validate().is_ok());

        // Valid: high value
        let high_request = SetMaxConcurrentRequest::new(10000);
        assert!(high_request.validate().is_ok());
    }
}