use crate::domain::factors::FactorTape;
use crate::domain::series::{SeriesBuilder, SeriesSnapshot, SnapshotCache};
use crate::infrastructure::{SimulationSnapshotRepository, SimulationV2Config, SnapshotRecord};
use crate::session::model::SessionState;
use crate::session::snapshot_record::{snapshot_quote_count, snapshot_record};
use crate::session::store::SimulationStore;
use crate::session::{SessionV2, SimulationParametersV2};
use crate::utils::ChainError;
use std::collections::HashMap;
use std::sync::atomic::{AtomicUsize, Ordering};
use std::sync::{Arc, Mutex};
use std::time::Instant;
use tokio::sync::{broadcast, mpsc};
use tracing::{debug, info, instrument, warn};
use uuid::Uuid;
struct TapeEntry {
tape: FactorTape,
last_access: Instant,
}
pub struct SimulationManager {
store: Arc<dyn SimulationStore>,
config: SimulationV2Config,
tapes: Arc<Mutex<HashMap<Uuid, TapeEntry>>>,
builds: Mutex<HashMap<Uuid, broadcast::Sender<Result<FactorTape, String>>>>,
snapshots: Mutex<SnapshotCache>,
warehouse: Option<Warehouse>,
}
struct Warehouse {
repository: Arc<dyn SimulationSnapshotRepository>,
sender: mpsc::Sender<SnapshotRecord>,
queued_contracts: Arc<AtomicUsize>,
}
const SNAPSHOT_QUEUE_DEPTH: usize = 1_024;
const SNAPSHOT_QUEUE_CONTRACTS: usize = 4_000_000;
impl SimulationManager {
#[must_use]
pub fn new(store: Arc<dyn SimulationStore>, config: SimulationV2Config) -> Self {
Self {
store,
config,
tapes: Arc::new(Mutex::new(HashMap::new())),
builds: Mutex::new(HashMap::new()),
snapshots: Mutex::new(SnapshotCache::with_bounds(
config.max_cached_snapshots,
config.max_cached_snapshot_contracts,
)),
warehouse: None,
}
}
#[must_use]
pub fn with_warehouse(mut self, repository: Arc<dyn SimulationSnapshotRepository>) -> Self {
let (sender, mut receiver) = mpsc::channel::<SnapshotRecord>(SNAPSHOT_QUEUE_DEPTH);
let queued_contracts = Arc::new(AtomicUsize::new(0));
let writer_contracts = Arc::clone(&queued_contracts);
let warehouse = Arc::clone(&repository);
tokio::spawn(async move {
while let Some(record) = receiver.recv().await {
let simulation = record.simulation;
let step = record.step;
let contracts = record.quote_count();
let result = warehouse.persist(record).await;
writer_contracts.fetch_sub(contracts, Ordering::SeqCst);
if let Err(error) = result {
warn!(
simulation_id = %simulation,
step,
error = %error,
"Could not file the snapshot; the step can be replayed and rewritten"
);
}
}
});
self.warehouse = Some(Warehouse {
repository,
sender,
queued_contracts,
});
self
}
#[must_use]
pub fn warehouse(&self) -> Option<Arc<dyn SimulationSnapshotRepository>> {
self.warehouse
.as_ref()
.map(|warehouse| Arc::clone(&warehouse.repository))
}
#[must_use]
pub fn config(&self) -> SimulationV2Config {
self.config
}
#[instrument(skip(self, parameters), level = "debug")]
pub(crate) async fn create(
&self,
parameters: SimulationParametersV2,
) -> Result<SessionV2, ChainError> {
let simulation = SessionV2::new(parameters);
self.store.create(simulation.clone()).await?;
info!(
simulation_id = %simulation.id,
steps = simulation.total_steps,
seed = simulation.parameters.seed,
"Created a v2 rolling simulation"
);
Ok(simulation)
}
#[instrument(skip(self), level = "debug")]
pub(crate) async fn get(&self, id: Uuid) -> Result<SessionV2, ChainError> {
self.store.get(id).await
}
#[instrument(skip(self), level = "debug")]
pub(crate) async fn peek(&self, id: Uuid) -> Result<(SessionV2, SeriesSnapshot), ChainError> {
let simulation = self.store.get(id).await?;
Self::reject_terminal(&simulation, "no current step")?;
let snapshot = self
.snapshot_at(&simulation, simulation.current_step)
.await?;
Ok((simulation, snapshot))
}
#[instrument(skip(self), level = "debug")]
pub(crate) async fn advance(
&self,
id: Uuid,
) -> Result<(SessionV2, SeriesSnapshot), ChainError> {
let mut simulation = self.store.get(id).await?;
let expected_version = simulation.version;
Self::reject_terminal(&simulation, "no further steps")?;
let snapshot = self
.snapshot_at(&simulation, simulation.current_step)
.await?;
simulation.current_step = simulation
.current_step
.checked_add(1)
.ok_or_else(|| ChainError::Internal("the cursor overflowed".to_string()))?;
simulation.state = if simulation.is_complete() {
SessionState::Completed
} else {
SessionState::InProgress
};
simulation.bump_version()?;
self.store
.save_cas(simulation.clone(), expected_version)
.await?;
self.file_snapshot(&simulation, &snapshot);
if simulation.state == SessionState::Completed {
self.evict(id);
debug!(simulation_id = %id, "Simulation completed; cached state evicted");
}
Ok((simulation, snapshot))
}
fn file_snapshot(&self, simulation: &SessionV2, snapshot: &SeriesSnapshot) {
let Some(warehouse) = &self.warehouse else {
return;
};
let incoming = snapshot_quote_count(snapshot);
let queued = warehouse.queued_contracts.load(Ordering::SeqCst);
if warehouse.sender.capacity() == 0
|| queued.saturating_add(incoming) > SNAPSHOT_QUEUE_CONTRACTS
{
warn!(
simulation_id = %simulation.id,
step = snapshot.step,
queued,
"The snapshot queue is full; the step was not filed and can be replayed"
);
return;
}
let record = snapshot_record(simulation.id, &simulation.parameters.symbol, snapshot);
warehouse
.queued_contracts
.fetch_add(incoming, Ordering::SeqCst);
if let Err(error) = warehouse.sender.try_send(record) {
warehouse
.queued_contracts
.fetch_sub(incoming, Ordering::SeqCst);
warn!(
simulation_id = %simulation.id,
step = snapshot.step,
error = %error,
"The snapshot queue is full; the step was not filed and can be replayed"
);
}
}
#[instrument(skip(self), level = "debug")]
pub(crate) async fn delete(&self, id: Uuid) -> Result<bool, ChainError> {
let deleted = self.store.delete(id).await?;
self.evict(id);
Ok(deleted)
}
#[instrument(skip(self), level = "debug")]
pub async fn cleanup(&self) -> Result<Vec<Uuid>, ChainError> {
let expired = self.store.cleanup().await?;
for id in &expired {
self.evict(*id);
}
Ok(expired)
}
#[must_use]
pub fn cached_tapes(&self) -> usize {
match self.tapes.lock() {
Ok(tapes) => tapes.len(),
Err(poisoned) => poisoned.into_inner().len(),
}
}
#[must_use]
pub fn cached_snapshots(&self) -> usize {
match self.snapshots.lock() {
Ok(snapshots) => snapshots.len(),
Err(poisoned) => poisoned.into_inner().len(),
}
}
fn reject_terminal(simulation: &SessionV2, what: &str) -> Result<(), ChainError> {
if simulation.state == SessionState::Completed || simulation.is_complete() {
return Err(ChainError::SimulatorError(format!(
"simulation completed; {what}"
)));
}
if simulation.state == SessionState::Error {
return Err(ChainError::InvalidState(
"simulation is in error state".to_string(),
));
}
Ok(())
}
async fn snapshot_at(
&self,
simulation: &SessionV2,
step: usize,
) -> Result<SeriesSnapshot, ChainError> {
if let Some(cached) = self.cached_snapshot(simulation.id, step) {
return Ok(cached);
}
let tape = self.tape_for(simulation).await?;
let snapshot = SeriesBuilder::new(&simulation.parameters, &tape)?.snapshot(step)?;
self.cache_snapshot(simulation.id, snapshot.clone());
Ok(snapshot)
}
fn cached_snapshot(&self, id: Uuid, step: usize) -> Option<SeriesSnapshot> {
let mut snapshots = match self.snapshots.lock() {
Ok(snapshots) => snapshots,
Err(poisoned) => poisoned.into_inner(),
};
snapshots.get(id, step).cloned()
}
fn cache_snapshot(&self, id: Uuid, snapshot: SeriesSnapshot) {
let mut snapshots = match self.snapshots.lock() {
Ok(snapshots) => snapshots,
Err(poisoned) => poisoned.into_inner(),
};
snapshots.insert(id, snapshot);
}
async fn tape_for(&self, simulation: &SessionV2) -> Result<FactorTape, ChainError> {
if let Some(tape) = self.cached_tape(simulation.id) {
return Ok(tape);
}
let id = simulation.id;
let subscription = {
let mut builds = match self.builds.lock() {
Ok(builds) => builds,
Err(poisoned) => poisoned.into_inner(),
};
match builds.get(&id) {
Some(running) => Some(running.subscribe()),
None => {
let (sender, _) = broadcast::channel(1);
builds.insert(id, sender);
None
}
}
};
if let Some(mut waiting) = subscription {
return match waiting.recv().await {
Ok(Ok(tape)) => Ok(tape),
Ok(Err(reason)) => Err(ChainError::Internal(reason)),
Err(_) => self.build_tape(simulation).await,
};
}
let result = self.build_tape(simulation).await;
let sender = {
let mut builds = match self.builds.lock() {
Ok(builds) => builds,
Err(poisoned) => poisoned.into_inner(),
};
builds.remove(&id)
};
if let Some(sender) = sender {
let published = match &result {
Ok(tape) => Ok(tape.clone()),
Err(error) => Err(error.to_string()),
};
let _ = sender.send(published);
}
result
}
async fn build_tape(&self, simulation: &SessionV2) -> Result<FactorTape, ChainError> {
let parameters = simulation.parameters.clone();
let id = simulation.id;
let tapes = Arc::clone(&self.tapes);
let max_cached_tapes = self.config.max_cached_tapes;
tokio::task::spawn_blocking(move || {
let tape = FactorTape::build(¶meters, ¶meters.method)?;
Self::cache_tape(&tapes, max_cached_tapes, id, tape.clone());
Ok(tape)
})
.await
.map_err(|e| ChainError::Internal(format!("the factor tape build did not finish: {e}")))?
}
fn cached_tape(&self, id: Uuid) -> Option<FactorTape> {
let mut tapes = match self.tapes.lock() {
Ok(tapes) => tapes,
Err(poisoned) => poisoned.into_inner(),
};
let entry = tapes.get_mut(&id)?;
entry.last_access = Instant::now();
Some(entry.tape.clone())
}
fn cache_tape(
tapes: &Mutex<HashMap<Uuid, TapeEntry>>,
max_cached_tapes: usize,
id: Uuid,
tape: FactorTape,
) {
let mut tapes = match tapes.lock() {
Ok(tapes) => tapes,
Err(poisoned) => poisoned.into_inner(),
};
tapes.remove(&id);
let max = max_cached_tapes;
debug_assert!(
max >= 1,
"the configured capacity is validated >= 1 at load"
);
while tapes.len() > max - 1 {
let victim = tapes
.iter()
.min_by_key(|(_, entry)| entry.last_access)
.map(|(id, _)| *id);
match victim {
Some(victim) => {
tapes.remove(&victim);
}
None => break,
}
}
tapes.insert(
id,
TapeEntry {
tape,
last_access: Instant::now(),
},
);
}
fn evict(&self, id: Uuid) {
match self.tapes.lock() {
Ok(mut tapes) => {
tapes.remove(&id);
}
Err(poisoned) => {
poisoned.into_inner().remove(&id);
}
}
match self.snapshots.lock() {
Ok(mut snapshots) => {
snapshots.evict_simulation(id);
}
Err(poisoned) => {
poisoned.into_inner().evict_simulation(id);
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::api::rest::models::{ApiTimeFrame, ApiWalkType};
use crate::api::rest::requests_v2::CreateSimulationRequest;
use crate::infrastructure::{ContractQuote, ContractSeriesQuery, SnapshotRecord};
use crate::session::store::InMemorySimulationStore;
use crate::session::{ExpiryRule, ExpiryRuleKind};
use chrono::{TimeZone, Utc, Weekday};
fn request(steps: usize) -> CreateSimulationRequest {
let rules = vec![
match ExpiryRule::new("zero_dte", ExpiryRuleKind::Daily, 1) {
Ok(rule) => rule,
Err(error) => panic!("the test rule must be valid: {error}"),
},
match ExpiryRule::new(
"weeklies",
ExpiryRuleKind::weekly([Weekday::Mon, Weekday::Fri]),
2,
) {
Ok(rule) => rule,
Err(error) => panic!("the test rule must be valid: {error}"),
},
];
let start_at = match Utc.with_ymd_and_hms(2026, 1, 5, 14, 30, 0).single() {
Some(instant) => instant,
None => panic!("the test instant must be valid"),
};
CreateSimulationRequest {
symbol: "SPX".to_string(),
steps,
start_at: Some(start_at),
step_interval_seconds: Some(86_400),
timezone: "America/New_York".to_string(),
calendar: None,
expiration_time: "17:00".to_string(),
schedules: rules,
initial_price: 5000.0,
volatility: 0.18,
risk_free_rate: 0.04,
dividend_yield: 0.0,
method: ApiWalkType::Brownian {
dt: 1.0 / 252.0,
drift: 0.0,
volatility: 0.18,
},
time_frame: ApiTimeFrame::Day,
chain_size: Some(3),
strike_interval: Some(25.0),
skew_slope: None,
smile_curve: None,
spread: Some(0.02),
seed: Some(42),
}
}
fn parameters(steps: usize) -> SimulationParametersV2 {
match SimulationParametersV2::try_from(request(steps)) {
Ok(parameters) => parameters,
Err(error) => panic!("the request must convert: {error}"),
}
}
fn manager() -> SimulationManager {
SimulationManager::new(
Arc::new(InMemorySimulationStore::new()),
SimulationV2Config::default(),
)
}
#[derive(Default)]
struct RecordingWarehouse {
filed: Mutex<Vec<(Uuid, usize)>>,
fail: bool,
}
impl RecordingWarehouse {
fn failing() -> Self {
Self {
filed: Mutex::new(Vec::new()),
fail: true,
}
}
fn filed(&self) -> Vec<(Uuid, usize)> {
match self.filed.lock() {
Ok(filed) => filed.clone(),
Err(poisoned) => poisoned.into_inner().clone(),
}
}
}
#[async_trait::async_trait]
impl SimulationSnapshotRepository for RecordingWarehouse {
async fn persist(&self, record: SnapshotRecord) -> Result<(), ChainError> {
if self.fail {
return Err(ChainError::Internal("the warehouse is down".to_string()));
}
match self.filed.lock() {
Ok(mut filed) => filed.push((record.simulation, record.step)),
Err(poisoned) => poisoned.into_inner().push((record.simulation, record.step)),
}
Ok(())
}
async fn get(
&self,
_simulation: Uuid,
_generation: u64,
_step: usize,
) -> Result<Option<SnapshotRecord>, ChainError> {
Ok(None)
}
async fn read_range(
&self,
_simulation: Uuid,
_generation: u64,
_from_step: usize,
_to_step: usize,
) -> Result<Vec<SnapshotRecord>, ChainError> {
Ok(Vec::new())
}
async fn contract_series(
&self,
_query: ContractSeriesQuery,
) -> Result<Vec<ContractQuote>, ChainError> {
Ok(Vec::new())
}
}
#[derive(Default)]
struct StallingWarehouse {
started: AtomicUsize,
}
impl StallingWarehouse {
fn started(&self) -> usize {
self.started.load(Ordering::SeqCst)
}
}
#[async_trait::async_trait]
impl SimulationSnapshotRepository for StallingWarehouse {
async fn persist(&self, _record: SnapshotRecord) -> Result<(), ChainError> {
self.started.fetch_add(1, Ordering::SeqCst);
std::future::pending::<()>().await;
Ok(())
}
async fn get(
&self,
_simulation: Uuid,
_generation: u64,
_step: usize,
) -> Result<Option<SnapshotRecord>, ChainError> {
Ok(None)
}
async fn read_range(
&self,
_simulation: Uuid,
_generation: u64,
_from_step: usize,
_to_step: usize,
) -> Result<Vec<SnapshotRecord>, ChainError> {
Ok(Vec::new())
}
async fn contract_series(
&self,
_query: ContractSeriesQuery,
) -> Result<Vec<ContractQuote>, ChainError> {
Ok(Vec::new())
}
}
async fn settle() {
for _ in 0..16 {
tokio::task::yield_now().await;
}
}
#[tokio::test]
async fn test_the_simulation_id_does_not_reach_the_tape() {
let manager = manager();
let first = created(&manager, 3).await;
let second = created(&manager, 3).await;
assert_ne!(first.id, second.id, "ids are random, so two differ");
assert_eq!(
first.parameters.seed, second.parameters.seed,
"the fixture must pin the seed, or this proves nothing"
);
for _ in 0..3 {
let left = match manager.advance(first.id).await {
Ok((_, snapshot)) => snapshot,
Err(error) => panic!("the first simulation must advance: {error}"),
};
let right = match manager.advance(second.id).await {
Ok((_, snapshot)) => snapshot,
Err(error) => panic!("the second simulation must advance: {error}"),
};
assert_eq!(
left, right,
"step {} differs between two simulations that share every parameter",
left.step
);
}
}
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
async fn test_concurrent_first_reads_share_one_build() {
let manager = Arc::new(manager());
let simulation = created(&manager, 3).await;
let mut readers = Vec::new();
for _ in 0..8 {
let manager = Arc::clone(&manager);
let id = simulation.id;
readers.push(tokio::spawn(async move { manager.peek(id).await }));
}
let mut snapshots = Vec::new();
for reader in readers {
match reader.await {
Ok(Ok((_, snapshot))) => snapshots.push(snapshot),
Ok(Err(error)) => panic!("every reader must be served: {error}"),
Err(error) => panic!("a reader panicked: {error}"),
}
}
assert_eq!(snapshots.len(), 8);
for snapshot in &snapshots {
assert_eq!(
snapshot, &snapshots[0],
"every reader must see the same tape"
);
}
assert_eq!(
manager.cached_tapes(),
1,
"eight readers of one simulation must leave one tape"
);
}
#[tokio::test]
async fn test_an_advance_files_the_step_it_served() {
let warehouse = Arc::new(RecordingWarehouse::default());
let manager = SimulationManager::new(
Arc::new(InMemorySimulationStore::new()),
SimulationV2Config::default(),
)
.with_warehouse(Arc::clone(&warehouse) as Arc<dyn SimulationSnapshotRepository>);
let simulation = created(&manager, 3).await;
match manager.advance(simulation.id).await {
Ok(_) => {}
Err(error) => panic!("the advance must serve: {error}"),
}
settle().await;
assert_eq!(
warehouse.filed(),
vec![(simulation.id, 0)],
"the step the advance served is the step that is filed"
);
}
#[tokio::test]
async fn test_a_stalled_warehouse_stops_being_queued() {
let warehouse = Arc::new(StallingWarehouse::default());
let manager = SimulationManager::new(
Arc::new(InMemorySimulationStore::new()),
SimulationV2Config::default(),
)
.with_warehouse(Arc::clone(&warehouse) as Arc<dyn SimulationSnapshotRepository>);
for _ in 0..(SNAPSHOT_QUEUE_DEPTH + 8) {
let simulation = created(&manager, 2).await;
match manager.advance(simulation.id).await {
Ok(_) => {}
Err(error) => panic!("the advance must serve regardless: {error}"),
}
}
settle().await;
assert!(
warehouse.started() <= SNAPSHOT_QUEUE_DEPTH + 1,
"a stalled warehouse must stop receiving, got {} starts",
warehouse.started()
);
}
#[tokio::test]
async fn test_a_failing_warehouse_does_not_fail_the_advance() {
let warehouse = Arc::new(RecordingWarehouse::failing());
let manager = SimulationManager::new(
Arc::new(InMemorySimulationStore::new()),
SimulationV2Config::default(),
)
.with_warehouse(warehouse as Arc<dyn SimulationSnapshotRepository>);
let simulation = created(&manager, 3).await;
match manager.advance(simulation.id).await {
Ok((advanced, _)) => assert_eq!(advanced.current_step, 1, "the cursor still moved"),
Err(error) => panic!("a warehouse failure must not fail the advance: {error}"),
}
settle().await;
}
#[tokio::test]
async fn test_a_peek_files_nothing() {
let warehouse = Arc::new(RecordingWarehouse::default());
let manager = SimulationManager::new(
Arc::new(InMemorySimulationStore::new()),
SimulationV2Config::default(),
)
.with_warehouse(Arc::clone(&warehouse) as Arc<dyn SimulationSnapshotRepository>);
let simulation = created(&manager, 3).await;
match manager.peek(simulation.id).await {
Ok(_) => {}
Err(error) => panic!("the peek must serve: {error}"),
}
settle().await;
assert!(warehouse.filed().is_empty(), "a peek persists nothing");
}
#[tokio::test]
async fn test_a_manager_without_a_warehouse_serves_normally() {
let manager = manager();
let simulation = created(&manager, 2).await;
match manager.advance(simulation.id).await {
Ok((advanced, snapshot)) => {
assert_eq!(advanced.current_step, 1);
assert_eq!(snapshot.step, 0);
}
Err(error) => panic!("the advance must serve: {error}"),
}
}
async fn created(manager: &SimulationManager, steps: usize) -> SessionV2 {
match manager.create(parameters(steps)).await {
Ok(simulation) => simulation,
Err(error) => panic!("the simulation must be created: {error}"),
}
}
#[tokio::test]
async fn test_create_then_get_returns_the_simulation() {
let manager = manager();
let created = created(&manager, 5).await;
match manager.get(created.id).await {
Ok(loaded) => {
assert_eq!(loaded, created);
assert_eq!(loaded.current_step, 0);
assert_eq!(loaded.state, SessionState::Initialized);
}
Err(error) => panic!("the simulation must load: {error}"),
}
}
#[tokio::test]
async fn test_creation_does_not_build_the_tape() {
let manager = manager();
let created = created(&manager, 5).await;
assert_eq!(manager.cached_tapes(), 0);
match manager.peek(created.id).await {
Ok(_) => assert_eq!(manager.cached_tapes(), 1),
Err(error) => panic!("the peek must succeed: {error}"),
}
}
#[tokio::test]
async fn test_the_tape_cache_still_honours_its_capacity() {
let config = SimulationV2Config {
max_cached_tapes: 2,
..SimulationV2Config::default()
};
let manager = SimulationManager::new(Arc::new(InMemorySimulationStore::new()), config);
for _ in 0..4 {
let created = created(&manager, 5).await;
if let Err(error) = manager.peek(created.id).await {
panic!("the peek must succeed: {error}");
}
}
assert_eq!(
manager.cached_tapes(),
2,
"four tapes were built under a cap of two"
);
}
#[tokio::test]
async fn test_a_peek_is_repeatable_and_changes_nothing() {
let manager = manager();
let created = created(&manager, 5).await;
let first = match manager.peek(created.id).await {
Ok((_, snapshot)) => snapshot,
Err(error) => panic!("the peek must succeed: {error}"),
};
let second = match manager.peek(created.id).await {
Ok((_, snapshot)) => snapshot,
Err(error) => panic!("the peek must succeed: {error}"),
};
assert_eq!(first, second);
match manager.get(created.id).await {
Ok(loaded) => {
assert_eq!(loaded.current_step, 0, "a peek must not move the cursor");
assert_eq!(loaded.version, created.version, "a peek must not persist");
assert_eq!(loaded.state, SessionState::Initialized);
}
Err(error) => panic!("the simulation must load: {error}"),
}
}
#[tokio::test]
async fn test_an_advance_serves_then_advances() {
let manager = manager();
let created = created(&manager, 5).await;
let peeked = match manager.peek(created.id).await {
Ok((_, snapshot)) => snapshot,
Err(error) => panic!("the peek must succeed: {error}"),
};
let (advanced, served) = match manager.advance(created.id).await {
Ok(result) => result,
Err(error) => panic!("the advance must succeed: {error}"),
};
assert_eq!(
served, peeked,
"the advance must serve the snapshot the peek showed"
);
assert_eq!(advanced.current_step, 1);
assert_eq!(advanced.state, SessionState::InProgress);
}
#[tokio::test]
async fn test_walking_serves_every_index_once_then_completes() {
let manager = manager();
let created = created(&manager, 3).await;
let mut served = Vec::new();
for _ in 0..3 {
match manager.advance(created.id).await {
Ok((_, snapshot)) => served.push(snapshot.step),
Err(error) => panic!("the advance must succeed: {error}"),
}
}
assert_eq!(served, vec![0, 1, 2]);
match manager.get(created.id).await {
Ok(loaded) => assert_eq!(loaded.state, SessionState::Completed),
Err(error) => panic!("the simulation must load: {error}"),
}
match manager.advance(created.id).await {
Err(ChainError::SimulatorError(message)) => assert!(message.contains("completed")),
other => panic!("expected the exhausted path, got {other:?}"),
}
match manager.peek(created.id).await {
Err(ChainError::SimulatorError(message)) => assert!(message.contains("completed")),
other => panic!("expected the exhausted path, got {other:?}"),
}
}
#[tokio::test]
async fn test_completion_evicts_the_cached_state() {
let manager = manager();
let created = created(&manager, 1).await;
match manager.advance(created.id).await {
Ok(_) => {}
Err(error) => panic!("the advance must succeed: {error}"),
}
assert_eq!(manager.cached_tapes(), 0);
assert_eq!(manager.cached_snapshots(), 0);
}
#[tokio::test]
async fn test_a_lost_race_is_a_conflict() {
let store = Arc::new(InMemorySimulationStore::new());
let manager = SimulationManager::new(store.clone(), SimulationV2Config::default());
let created = created(&manager, 5).await;
match manager.advance(created.id).await {
Ok(_) => {}
Err(error) => panic!("the first advance must succeed: {error}"),
}
let mut stale = created.clone();
stale.current_step = 1;
stale.state = SessionState::InProgress;
let expected = match stale.bump_version() {
Ok(expected) => expected,
Err(error) => panic!("must bump: {error}"),
};
match store.save_cas(stale, expected).await {
Err(ChainError::Conflict(_)) => {}
other => panic!("expected Conflict, got {other:?}"),
}
}
#[tokio::test]
async fn test_delete_removes_the_simulation_and_its_caches() {
let manager = manager();
let created = created(&manager, 5).await;
match manager.peek(created.id).await {
Ok(_) => {}
Err(error) => panic!("the peek must succeed: {error}"),
}
assert_eq!(manager.cached_tapes(), 1);
match manager.delete(created.id).await {
Ok(deleted) => assert!(deleted),
Err(error) => panic!("the delete must succeed: {error}"),
}
assert_eq!(manager.cached_tapes(), 0);
assert_eq!(manager.cached_snapshots(), 0);
assert!(manager.get(created.id).await.is_err());
}
#[tokio::test]
async fn test_deleting_a_missing_simulation_is_not_an_error() {
let manager = manager();
match manager.delete(Uuid::new_v4()).await {
Ok(deleted) => assert!(!deleted),
Err(error) => panic!("a missing delete must not error: {error}"),
}
}
#[tokio::test]
async fn test_an_unknown_id_is_not_found() {
let manager = manager();
let missing = Uuid::new_v4();
assert!(matches!(
manager.get(missing).await,
Err(ChainError::NotFound(_))
));
assert!(matches!(
manager.peek(missing).await,
Err(ChainError::NotFound(_))
));
assert!(matches!(
manager.advance(missing).await,
Err(ChainError::NotFound(_))
));
}
#[tokio::test]
async fn test_cleanup_expires_and_evicts() {
let store = Arc::new(InMemorySimulationStore::with_idle_retention(
std::time::Duration::from_secs(1),
));
let manager = SimulationManager::new(store, SimulationV2Config::default());
let created = created(&manager, 5).await;
match manager.peek(created.id).await {
Ok(_) => {}
Err(error) => panic!("the peek must succeed: {error}"),
}
assert_eq!(manager.cached_tapes(), 1);
let mut aged = created.clone();
aged.updated_at = std::time::SystemTime::now() - std::time::Duration::from_secs(3_600);
let expected = aged.version;
match manager.store.save_cas(aged, expected).await {
Ok(()) => {}
Err(error) => panic!("the aged document must save: {error}"),
}
match manager.cleanup().await {
Ok(expired) => assert_eq!(expired, vec![created.id]),
Err(error) => panic!("the cleanup must succeed: {error}"),
}
assert_eq!(manager.cached_tapes(), 0);
assert_eq!(manager.cached_snapshots(), 0);
}
#[tokio::test]
async fn test_an_evicted_tape_rebuilds_identically() {
let manager = manager();
let created = created(&manager, 4).await;
let before = match manager.peek(created.id).await {
Ok((_, snapshot)) => snapshot,
Err(error) => panic!("the peek must succeed: {error}"),
};
manager.evict(created.id);
assert_eq!(manager.cached_tapes(), 0);
let after = match manager.peek(created.id).await {
Ok((_, snapshot)) => snapshot,
Err(error) => panic!("the peek must succeed: {error}"),
};
assert_eq!(before, after, "a rebuild must be indistinguishable");
}
}