simulator-api 0.20.0

Wire-protocol types for the Solana simulator backtest WebSocket API
Documentation
use chrono::{DateTime, Duration, Utc};
use serde::{Deserialize, Serialize};

/// Response body of `GET /usage`.
///
/// `since` is the start of the UTC day it falls in, so it may precede the
/// requested value.
#[derive(Debug, Serialize, Deserialize)]
pub struct UsageReport {
    pub api_key_name: String,
    pub since: DateTime<Utc>,
    pub until: DateTime<Utc>,
    sessions: SessionCounts,
    compute: ComputeTotals,
    /// One bucket per UTC day in `(since, until]`, ordered and gapless: an idle
    /// day is present with zero counts, and a session ending on a midnight
    /// belongs to the bucket that midnight closes. The series sums to
    /// `sessions` and `compute`; it is empty only from a server predating it.
    #[serde(default)]
    pub daily: Vec<UsageBucket>,
    /// Present only for keys with the `build_bundle` feature.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub bundle_builds: Option<BundleBuildUsage>,
}

impl UsageReport {
    /// Folds the totals from `daily`, which is the only way to set them.
    pub fn new(
        api_key_name: String,
        since: DateTime<Utc>,
        until: DateTime<Utc>,
        daily: Vec<UsageBucket>,
        bundle_builds: Option<BundleBuildUsage>,
    ) -> Self {
        let (sessions, compute) = daily.iter().fold(
            (SessionCounts::default(), ComputeTotals::default()),
            |(sessions, compute), bucket| {
                (
                    sessions.saturating_add(bucket.sessions),
                    compute.saturating_add(bucket.compute),
                )
            },
        );
        Self {
            api_key_name,
            since,
            until,
            sessions,
            compute,
            daily,
            bundle_builds,
        }
    }

    /// The sum over `daily`, except on a response from a server predating it,
    /// where it is the total that server reported for the whole window.
    pub fn sessions(&self) -> SessionCounts {
        self.sessions
    }

    /// The sum over `daily`, except on a response from a server predating it,
    /// where it is the total that server reported for the whole window.
    pub fn compute(&self) -> ComputeTotals {
        self.compute
    }
}

/// What one whole [`UsageBucket`] spans.
pub const BUCKET_SPAN: Duration = Duration::days(1);

/// One UTC day of a [`UsageReport`], covering `(start, end]`. `end` is the
/// following midnight, except for a last bucket that ends at the report's
/// `until`.
#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq)]
pub struct UsageBucket {
    pub start: DateTime<Utc>,
    pub end: DateTime<Utc>,
    pub sessions: SessionCounts,
    pub compute: ComputeTotals,
}

impl UsageBucket {
    /// Only the last bucket of a report can be partial.
    pub fn is_partial(&self) -> bool {
        self.end.signed_duration_since(self.start) < BUCKET_SPAN
    }

    /// A day with no activity, kept only to leave the series gapless.
    pub fn is_empty(&self) -> bool {
        self.sessions == SessionCounts::default() && self.compute == ComputeTotals::default()
    }
}

/// Bundle-build usage for the window.
#[derive(Debug, Default, Serialize, Deserialize, Clone, Copy, PartialEq, Eq)]
pub struct BundleBuildUsage {
    pub requested: u64,
}

/// Point counts by outcome, not session-deduplicated, so the three need not
/// balance: a pre-start failure has no `started`, a cross-manager handoff
/// re-emits it. Billing uses the compute totals, which derive only from the
/// terminal points.
///
/// Within a [`UsageBucket`], `started` counts sessions that began that day and
/// `completed`/`failed` those that ended that day.
#[derive(Debug, Default, Serialize, Deserialize, Clone, Copy, PartialEq, Eq)]
pub struct SessionCounts {
    pub started: u64,
    pub completed: u64,
    pub failed: u64,
}

/// A session contributes wholly to the window — or [`UsageBucket`] — its run
/// ended in, even when it ran across the boundary.
#[derive(Debug, Default, Serialize, Deserialize, Clone, Copy, PartialEq, Eq)]
pub struct ComputeTotals {
    pub executed_slot_count: u64,
    pub session_duration_ms: u64,
}

impl SessionCounts {
    pub fn saturating_add(self, other: Self) -> Self {
        Self {
            started: self.started.saturating_add(other.started),
            completed: self.completed.saturating_add(other.completed),
            failed: self.failed.saturating_add(other.failed),
        }
    }
}

impl ComputeTotals {
    pub fn saturating_add(self, other: Self) -> Self {
        Self {
            executed_slot_count: self
                .executed_slot_count
                .saturating_add(other.executed_slot_count),
            session_duration_ms: self
                .session_duration_ms
                .saturating_add(other.session_duration_ms),
        }
    }
}

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

    fn report(bundle_builds: Option<BundleBuildUsage>) -> UsageReport {
        UsageReport::new(
            "k".to_string(),
            "2026-01-01T00:00:00Z".parse().unwrap(),
            "2026-01-02T00:00:00Z".parse().unwrap(),
            vec![UsageBucket {
                start: "2026-01-01T00:00:00Z".parse().unwrap(),
                end: "2026-01-02T00:00:00Z".parse().unwrap(),
                sessions: SessionCounts::default(),
                compute: ComputeTotals::default(),
            }],
            bundle_builds,
        )
    }

    fn bucket(day: &str, sessions: SessionCounts, compute: ComputeTotals) -> UsageBucket {
        let start: DateTime<Utc> = format!("2026-01-{day}T00:00:00Z").parse().unwrap();
        UsageBucket {
            start,
            end: start + BUCKET_SPAN,
            sessions,
            compute,
        }
    }

    #[test]
    fn new_sums_the_totals_from_the_buckets() {
        let report = UsageReport::new(
            "k".to_string(),
            "2026-01-01T00:00:00Z".parse().unwrap(),
            "2026-01-03T00:00:00Z".parse().unwrap(),
            vec![
                bucket(
                    "01",
                    SessionCounts {
                        started: 1,
                        completed: 1,
                        failed: 0,
                    },
                    ComputeTotals {
                        executed_slot_count: 5,
                        session_duration_ms: 9,
                    },
                ),
                bucket(
                    "02",
                    SessionCounts {
                        started: 2,
                        completed: 1,
                        failed: 1,
                    },
                    ComputeTotals {
                        executed_slot_count: 7,
                        session_duration_ms: 3,
                    },
                ),
            ],
            None,
        );
        assert_eq!(
            report.sessions(),
            SessionCounts {
                started: 3,
                completed: 2,
                failed: 1
            }
        );
        assert_eq!(
            report.compute(),
            ComputeTotals {
                executed_slot_count: 12,
                session_duration_ms: 12
            }
        );
    }

    #[test]
    fn new_totals_of_no_buckets_are_zero() {
        let report = UsageReport::new(
            "k".to_string(),
            "2026-01-01T00:00:00Z".parse().unwrap(),
            "2026-01-01T00:00:00Z".parse().unwrap(),
            vec![],
            None,
        );
        assert_eq!(report.sessions(), SessionCounts::default());
        assert_eq!(report.compute(), ComputeTotals::default());
    }

    #[test]
    fn usage_report_absent_bundle_builds_round_trips_to_none() {
        let json = serde_json::to_string(&report(None)).unwrap();
        assert!(!json.contains("bundle_builds"));
        let parsed: UsageReport = serde_json::from_str(&json).unwrap();
        assert_eq!(parsed.bundle_builds, None);
    }

    #[test]
    fn usage_report_absent_daily_round_trips_to_empty() {
        let json = r#"{"api_key_name":"k","since":"2026-01-01T00:00:00Z","until":"2026-01-02T00:00:00Z","sessions":{"started":1,"completed":1,"failed":0},"compute":{"executed_slot_count":5,"session_duration_ms":9}}"#;
        let parsed: UsageReport = serde_json::from_str(json).unwrap();
        assert!(parsed.daily.is_empty());
        // The totals stay on the wire so an empty `daily` does not read as no usage.
        assert_eq!(
            parsed.sessions(),
            SessionCounts {
                started: 1,
                completed: 1,
                failed: 0
            }
        );
        assert_eq!(
            parsed.compute(),
            ComputeTotals {
                executed_slot_count: 5,
                session_duration_ms: 9
            }
        );
    }

    #[test]
    fn usage_report_daily_round_trips() {
        let json = serde_json::to_string(&report(None)).unwrap();
        let parsed: UsageReport = serde_json::from_str(&json).unwrap();
        assert_eq!(parsed.daily, report(None).daily);
    }

    #[test]
    fn usage_report_present_bundle_builds_round_trips() {
        let json = serde_json::to_string(&report(Some(BundleBuildUsage { requested: 3 }))).unwrap();
        let parsed: UsageReport = serde_json::from_str(&json).unwrap();
        assert_eq!(
            parsed.bundle_builds,
            Some(BundleBuildUsage { requested: 3 })
        );
    }
}