use crate::domain::factors::FactorTape;
use crate::domain::series::{SeriesBuilder, SeriesSnapshot, SnapshotCache};
use crate::infrastructure::{
MetricsCollector, SimulationSnapshotRepository, SimulationV2Config, SnapshotRecord,
};
use crate::session::model::SessionState;
use crate::session::snapshot_record::{snapshot_quote_count, snapshot_record};
use crate::session::store::{BuildClaim, SharedTapeCache, SimulationStore, tape_key};
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::{Duration, Instant};
use tokio::sync::{broadcast, mpsc};
use tracing::{debug, info, instrument, warn};
use uuid::Uuid;
const SHARED_BUILD_WAIT: Duration = Duration::from_secs(30);
const SHARED_BUILD_POLL: Duration = Duration::from_millis(50);
enum SharedBuild {
Owner(Option<String>),
Waited(FactorTape),
}
struct OwnedBuild {
id: Uuid,
key: String,
parameters: SimulationParametersV2,
tapes: Arc<Mutex<HashMap<Uuid, TapeEntry>>>,
builds: Arc<Mutex<HashMap<Uuid, TapeBuilds>>>,
shared: Option<Arc<dyn SharedTapeCache>>,
max_cached_tapes: usize,
shared_build_wait: Duration,
}
impl OwnedBuild {
async fn run(self, sender: TapeBuilds) {
let claim = self.claim().await;
let built_here = !matches!(claim, SharedBuild::Waited(_));
let token = match &claim {
SharedBuild::Owner(token) => token.clone(),
SharedBuild::Waited(_) => None,
};
let result = match claim {
SharedBuild::Waited(tape) => {
SimulationManager::cache_tape(
&self.tapes,
self.max_cached_tapes,
self.id,
tape.clone(),
);
Ok(tape)
}
SharedBuild::Owner(_) => {
SimulationManager::build_tape_into(
self.parameters.clone(),
self.id,
Arc::clone(&self.tapes),
self.max_cached_tapes,
)
.await
}
};
{
let mut builds = match self.builds.lock() {
Ok(builds) => builds,
Err(poisoned) => poisoned.into_inner(),
};
builds.remove(&self.id);
}
let published = match &result {
Ok(tape) => Ok(tape.clone()),
Err(error) => Err(error.to_string()),
};
let _ = sender.send(published);
if let (Some(shared), true, Ok(tape)) = (self.shared.as_ref(), built_here, &result) {
SimulationManager::share_tape_to(shared.as_ref(), &self.key, self.id, tape).await;
}
if let (Some(shared), Some(token)) = (self.shared.as_ref(), &token) {
shared.release_build(&self.key, token).await;
}
}
async fn claim(&self) -> SharedBuild {
let Some(shared) = self.shared.as_ref() else {
return SharedBuild::Owner(None);
};
match shared.claim_build(&self.key).await {
BuildClaim::Unclaimed => return SharedBuild::Owner(None),
BuildClaim::Held(token) => return SharedBuild::Owner(Some(token)),
BuildClaim::Taken => {}
}
debug!(
simulation_id = %self.id,
"another instance is building this tape; waiting for it"
);
let deadline = Instant::now() + self.shared_build_wait;
while Instant::now() < deadline {
tokio::time::sleep(SHARED_BUILD_POLL).await;
if let Some(tape) =
SimulationManager::shared_tape_from(shared.as_ref(), &self.key, self.id).await
{
return SharedBuild::Waited(tape);
}
}
warn!(
simulation_id = %self.id,
waited_secs = self.shared_build_wait.as_secs(),
"the instance building this tape did not publish in time; building it here"
);
SharedBuild::Owner(None)
}
}
struct TapeEntry {
tape: FactorTape,
last_access: Instant,
}
type SnapshotKey = (Uuid, usize);
type SnapshotBuilds = broadcast::Sender<Result<SeriesSnapshot, String>>;
type TapeBuilds = broadcast::Sender<Result<FactorTape, String>>;
pub struct SimulationManager {
store: Arc<dyn SimulationStore>,
config: SimulationV2Config,
tapes: Arc<Mutex<HashMap<Uuid, TapeEntry>>>,
builds: Arc<Mutex<HashMap<Uuid, TapeBuilds>>>,
snapshot_builds: Mutex<HashMap<SnapshotKey, SnapshotBuilds>>,
snapshots: Arc<Mutex<SnapshotCache>>,
shared_tapes: Option<Arc<dyn SharedTapeCache>>,
shared_build_wait: Duration,
snapshot_metrics: Option<Arc<MetricsCollector>>,
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 = 1_350_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: Arc::new(Mutex::new(HashMap::new())),
snapshot_builds: Mutex::new(HashMap::new()),
snapshots: Arc::new(Mutex::new(SnapshotCache::with_bounds(
config.max_cached_snapshots,
config.max_cached_snapshot_contracts,
))),
shared_tapes: None,
shared_build_wait: SHARED_BUILD_WAIT,
snapshot_metrics: None,
warehouse: None,
}
}
#[must_use]
pub fn with_shared_tapes(mut self, cache: Arc<dyn SharedTapeCache>) -> Self {
self.shared_tapes = Some(cache);
self
}
#[must_use]
pub fn with_shared_build_wait(mut self, wait: Duration) -> Self {
self.shared_build_wait = wait;
self
}
#[must_use]
pub fn with_snapshot_metrics(mut self, metrics: Arc<MetricsCollector>) -> Self {
self.snapshot_metrics = Some(metrics);
self
}
#[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);
let writer_metrics = self.snapshot_metrics.clone();
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 result.is_ok()
&& let Some(metrics) = writer_metrics.as_ref()
{
metrics.record_snapshot_rows_filed(contracts);
}
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);
self.forget_shared(id).await;
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);
self.forget_shared(*id).await;
}
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 key = (simulation.id, step);
let subscription = {
let mut builds = match self.snapshot_builds.lock() {
Ok(builds) => builds,
Err(poisoned) => poisoned.into_inner(),
};
match builds.get(&key) {
Some(running) => Some(running.subscribe()),
None => {
let (sender, _) = broadcast::channel(1);
builds.insert(key, sender);
None
}
}
};
if let Some(mut waiting) = subscription {
return match waiting.recv().await {
Ok(Ok(snapshot)) => Ok(snapshot),
Ok(Err(reason)) => Err(ChainError::Internal(reason)),
Err(_) => self.build_snapshot(simulation, step).await,
};
}
let result = self.build_snapshot(simulation, step).await;
let sender = {
let mut builds = match self.snapshot_builds.lock() {
Ok(builds) => builds,
Err(poisoned) => poisoned.into_inner(),
};
builds.remove(&key)
};
if let Some(sender) = sender {
let published = match &result {
Ok(snapshot) => Ok(snapshot.clone()),
Err(error) => Err(error.to_string()),
};
let _ = sender.send(published);
}
result
}
async fn build_snapshot(
&self,
simulation: &SessionV2,
step: usize,
) -> Result<SeriesSnapshot, ChainError> {
let tape = self.tape_for(simulation).await?;
let greek_snapshots = self.warehouse.is_some();
let parameters = simulation.parameters.clone();
let snapshots = Arc::clone(&self.snapshots);
let id = simulation.id;
crate::utils::admission::admit_blocking(move || {
let snapshot = SeriesBuilder::new(¶meters, &tape)?
.with_greek_snapshots(greek_snapshots)
.snapshot(step)?;
Self::cache_snapshot_into(&snapshots, id, snapshot.clone());
Ok(snapshot)
})
.await
}
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_into(snapshots: &Mutex<SnapshotCache>, id: Uuid, snapshot: SeriesSnapshot) {
let mut snapshots = match 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 key = tape_key(id, &simulation.parameters);
if let Some(tape) = self.shared_tape(&key, id).await {
Self::cache_tape(&self.tapes, self.config.max_cached_tapes, id, tape.clone());
return Ok(tape);
}
if let Some(tape) = self.cached_tape(id) {
return Ok(tape);
}
let (mut waiting, owned) = {
let mut builds = match self.builds.lock() {
Ok(builds) => builds,
Err(poisoned) => poisoned.into_inner(),
};
match builds.get(&id) {
Some(running) => (running.subscribe(), None),
None => {
let (sender, receiver) = broadcast::channel(1);
builds.insert(id, sender.clone());
(receiver, Some(sender))
}
}
};
if let Some(sender) = owned {
tokio::spawn(
OwnedBuild {
id,
key,
parameters: simulation.parameters.clone(),
tapes: Arc::clone(&self.tapes),
builds: Arc::clone(&self.builds),
shared: self.shared_tapes.clone(),
max_cached_tapes: self.config.max_cached_tapes,
shared_build_wait: self.shared_build_wait,
}
.run(sender),
);
}
match waiting.recv().await {
Ok(Ok(tape)) => Ok(tape),
Ok(Err(reason)) => Err(ChainError::Internal(reason)),
Err(_) => self.build_tape(simulation).await,
}
}
async fn build_tape(&self, simulation: &SessionV2) -> Result<FactorTape, ChainError> {
Self::build_tape_into(
simulation.parameters.clone(),
simulation.id,
Arc::clone(&self.tapes),
self.config.max_cached_tapes,
)
.await
}
async fn build_tape_into(
parameters: SimulationParametersV2,
id: Uuid,
tapes: Arc<Mutex<HashMap<Uuid, TapeEntry>>>,
max_cached_tapes: usize,
) -> Result<FactorTape, ChainError> {
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}")))?
}
async fn shared_tape(&self, key: &str, id: Uuid) -> Option<FactorTape> {
let shared = self.shared_tapes.as_ref()?;
Self::shared_tape_from(shared.as_ref(), key, id).await
}
async fn shared_tape_from(
shared: &dyn SharedTapeCache,
key: &str,
id: Uuid,
) -> Option<FactorTape> {
let encoded = shared.get(key).await?;
match serde_json::from_str::<FactorTape>(&encoded) {
Ok(tape) => Some(tape),
Err(error) => {
warn!(
%error,
simulation_id = %id,
"a shared tape did not decode; rebuilding it"
);
None
}
}
}
async fn share_tape_to(shared: &dyn SharedTapeCache, key: &str, id: Uuid, tape: &FactorTape) {
match serde_json::to_string(tape) {
Ok(encoded) => shared.put(key, &encoded).await,
Err(error) => warn!(
%error,
simulation_id = %id,
"a built tape did not encode; it stays local to this instance"
),
}
}
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(),
},
);
}
async fn forget_shared(&self, id: Uuid) {
if let Some(shared) = self.shared_tapes.as_ref() {
shared.forget_simulation(&id.to_string()).await;
}
}
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),
strike_ladder: Default::default(),
spread_proportional: None,
spread_moneyness_widening: None,
spread_tenor_widening: None,
spread_tick: None,
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 SharedTapes {
entries: Mutex<HashMap<String, String>>,
building: Mutex<std::collections::HashSet<String>>,
reads: AtomicUsize,
writes: AtomicUsize,
claims: AtomicUsize,
}
impl SharedTapes {
fn writes(&self) -> usize {
self.writes.load(Ordering::SeqCst)
}
fn reads(&self) -> usize {
self.reads.load(Ordering::SeqCst)
}
fn claims(&self) -> usize {
self.claims.load(Ordering::SeqCst)
}
fn keys(&self) -> Vec<String> {
match self.entries.lock() {
Ok(entries) => entries.keys().cloned().collect(),
Err(poisoned) => poisoned.into_inner().keys().cloned().collect(),
}
}
}
#[async_trait::async_trait]
impl SharedTapeCache for SharedTapes {
async fn get(&self, key: &str) -> Option<String> {
self.reads.fetch_add(1, Ordering::SeqCst);
match self.entries.lock() {
Ok(entries) => entries.get(key).cloned(),
Err(poisoned) => poisoned.into_inner().get(key).cloned(),
}
}
async fn put(&self, key: &str, encoded: &str) {
self.writes.fetch_add(1, Ordering::SeqCst);
match self.entries.lock() {
Ok(mut entries) => entries.insert(key.to_string(), encoded.to_string()),
Err(poisoned) => poisoned
.into_inner()
.insert(key.to_string(), encoded.to_string()),
};
}
async fn forget_simulation(&self, id: &str) {
let suffix = format!(":{id}");
match self.entries.lock() {
Ok(mut entries) => entries.retain(|key, _| !key.ends_with(&suffix)),
Err(poisoned) => poisoned
.into_inner()
.retain(|key, _| !key.ends_with(&suffix)),
}
}
async fn claim_build(&self, key: &str) -> BuildClaim {
self.claims.fetch_add(1, Ordering::SeqCst);
let mut held = match self.building.lock() {
Ok(held) => held,
Err(poisoned) => poisoned.into_inner(),
};
if held.insert(key.to_string()) {
BuildClaim::Held(format!("token:{key}"))
} else {
BuildClaim::Taken
}
}
async fn release_build(&self, key: &str, token: &str) {
let mut held = match self.building.lock() {
Ok(held) => held,
Err(poisoned) => poisoned.into_inner(),
};
if token == format!("token:{key}") {
held.remove(key);
}
}
}
struct BrokenTapes;
#[async_trait::async_trait]
impl SharedTapeCache for BrokenTapes {
async fn get(&self, _key: &str) -> Option<String> {
None
}
async fn put(&self, _key: &str, _encoded: &str) {}
async fn forget_simulation(&self, _id: &str) {}
async fn claim_build(&self, _key: &str) -> BuildClaim {
BuildClaim::Unclaimed
}
async fn release_build(&self, _key: &str, _token: &str) {}
}
#[derive(Default)]
struct RecordingWarehouse {
filed: Mutex<Vec<SnapshotRecord>>,
fail: bool,
}
impl RecordingWarehouse {
fn failing() -> Self {
Self {
filed: Mutex::new(Vec::new()),
fail: true,
}
}
fn records(&self) -> Vec<SnapshotRecord> {
match self.filed.lock() {
Ok(filed) => filed.clone(),
Err(poisoned) => poisoned.into_inner().clone(),
}
}
fn filed(&self) -> Vec<(Uuid, usize)> {
self.records()
.iter()
.map(|record| (record.simulation, record.step))
.collect()
}
}
#[async_trait::async_trait]
impl SimulationSnapshotRepository for RecordingWarehouse {
async fn ping(&self) -> Result<(), ChainError> {
Ok(())
}
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),
Err(poisoned) => poisoned.into_inner().push(record),
}
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 ping(&self) -> Result<(), ChainError> {
Ok(())
}
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_readers_of_one_step_share_one_snapshot_build() {
let manager = Arc::new(manager().with_warehouse(
Arc::new(RecordingWarehouse::default()) as Arc<dyn SimulationSnapshotRepository>
));
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 snapshot"
);
}
assert_eq!(
manager.cached_snapshots(),
1,
"eight readers of one step must leave one snapshot"
);
assert!(
match manager.snapshot_builds.lock() {
Ok(builds) => builds.is_empty(),
Err(poisoned) => poisoned.into_inner().is_empty(),
},
"the owner must stop being the owner once it has published"
);
}
#[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_registered_warehouse_files_the_greeks() {
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;
let records = warehouse.records();
let record = match records.first() {
Some(record) => record,
None => panic!("the advance must file a record"),
};
let quotes: Vec<_> = record
.expirations
.iter()
.flat_map(|expiration| expiration.quotes.iter())
.collect();
assert!(!quotes.is_empty(), "the filed record must carry quotes");
assert!(
quotes
.iter()
.all(|quote| quote.greeks_call.is_some() && quote.greeks_put.is_some()),
"every filed quote must carry both snapshots"
);
}
#[tokio::test]
async fn test_a_warehouse_does_not_change_the_served_market() {
let filing = SimulationManager::new(
Arc::new(InMemorySimulationStore::new()),
SimulationV2Config::default(),
)
.with_warehouse(
Arc::new(RecordingWarehouse::default()) as Arc<dyn SimulationSnapshotRepository>
);
let plain = SimulationManager::new(
Arc::new(InMemorySimulationStore::new()),
SimulationV2Config::default(),
);
let served = async |manager: &SimulationManager| {
let simulation = created(manager, 3).await;
match manager.peek(simulation.id).await {
Ok((_, snapshot)) => snapshot,
Err(error) => panic!("the peek must serve: {error}"),
}
};
let with_warehouse = served(&filing).await;
let without = served(&plain).await;
assert_eq!(
with_warehouse.spot, without.spot,
"the seeded price path must not depend on the warehouse"
);
assert_eq!(with_warehouse.base_volatility, without.base_volatility);
assert_eq!(with_warehouse.chains.len(), without.chains.len());
let mut compared = 0_usize;
for (filed, replayed) in with_warehouse.chains.iter().zip(without.chains.iter()) {
assert_eq!(filed.expires_at, replayed.expires_at);
assert_eq!(filed.days_to_expiration, replayed.days_to_expiration);
assert_eq!(
filed.chain.iter().count(),
replayed.chain.iter().count(),
"the two deployments must quote the same strikes"
);
for (left, right) in filed.chain.iter().zip(replayed.chain.iter()) {
compared += 1;
assert_eq!(left.strike_price, right.strike_price);
assert_eq!(left.implied_volatility, right.implied_volatility);
assert_eq!(left.call_bid, right.call_bid);
assert_eq!(left.call_ask, right.call_ask);
assert_eq!(left.call_middle, right.call_middle);
assert_eq!(left.put_bid, right.put_bid);
assert_eq!(left.put_ask, right.put_ask);
assert_eq!(left.put_middle, right.put_middle);
assert_eq!(left.delta_call, right.delta_call);
assert_eq!(left.delta_put, right.delta_put);
assert_eq!(left.gamma, right.gamma);
}
}
assert!(compared > 0, "the fixture must actually quote something");
assert!(
with_warehouse
.chains
.iter()
.flat_map(|chain| chain.chain.iter())
.all(|data| data.greeks_call.is_some())
);
assert!(
without
.chains
.iter()
.flat_map(|chain| chain.chain.iter())
.all(|data| data.greeks_call.is_none())
);
}
#[tokio::test]
async fn test_a_manager_without_a_warehouse_does_not_price_the_greeks() {
let manager = SimulationManager::new(
Arc::new(InMemorySimulationStore::new()),
SimulationV2Config::default(),
);
let simulation = created(&manager, 3).await;
let (_, snapshot) = match manager.peek(simulation.id).await {
Ok(served) => served,
Err(error) => panic!("the peek must serve: {error}"),
};
let contracts: Vec<_> = snapshot
.chains
.iter()
.flat_map(|chain| chain.chain.iter())
.collect();
assert!(!contracts.is_empty(), "the snapshot must quote something");
assert!(
contracts
.iter()
.all(|data| data.greeks_call.is_none() && data.greeks_put.is_none()),
"no snapshot should have been priced"
);
}
#[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_a_second_instance_serves_a_tape_it_did_not_build() {
let store = Arc::new(InMemorySimulationStore::new());
let shared = Arc::new(SharedTapes::default());
let first = SimulationManager::new(
Arc::clone(&store) as Arc<dyn SimulationStore>,
SimulationV2Config::default(),
)
.with_shared_tapes(Arc::clone(&shared) as Arc<dyn SharedTapeCache>);
let second = SimulationManager::new(
Arc::clone(&store) as Arc<dyn SimulationStore>,
SimulationV2Config::default(),
)
.with_shared_tapes(Arc::clone(&shared) as Arc<dyn SharedTapeCache>);
let created = match first.create(parameters(4)).await {
Ok(created) => created,
Err(error) => panic!("the simulation must be created: {error}"),
};
let served_first = match first.peek(created.id).await {
Ok(snapshot) => snapshot,
Err(error) => panic!("the first instance must serve: {error}"),
};
assert_eq!(shared.writes(), 1, "building a tape must share it");
assert_eq!(
shared.keys().len(),
1,
"one simulation is one shared entry: {:?}",
shared.keys()
);
let writes_after_build = shared.writes();
let served_second = match second.peek(created.id).await {
Ok(snapshot) => snapshot,
Err(error) => panic!("the second instance must serve: {error}"),
};
assert_eq!(
shared.writes(),
writes_after_build,
"the second instance wrote a tape, so it built one rather than reading the shared \
one"
);
assert!(shared.reads() >= 1, "the second instance must have looked");
assert_eq!(
second.cached_tapes(),
1,
"a shared hit must still populate the local cache, or every step pays the round trip"
);
let stored = match shared.entries.lock() {
Ok(entries) => entries.values().next().cloned(),
Err(poisoned) => poisoned.into_inner().values().next().cloned(),
};
let stored = match stored {
Some(stored) => stored,
None => panic!("the tape must be in the shared cache to be compared"),
};
let decoded: FactorTape = match serde_json::from_str(&stored) {
Ok(decoded) => decoded,
Err(error) => panic!("a shared tape must decode: {error}"),
};
let built = match FactorTape::build(&created.parameters, &created.parameters.method) {
Ok(built) => built,
Err(error) => panic!("the tape must build: {error}"),
};
assert_eq!(
decoded, built,
"the tape that came back from the shared cache is not the tape that was built"
);
assert!(decoded.len() > 1, "the tape must cover its steps");
assert!(
decoded
.rows()
.iter()
.any(|row| row.spot != decoded.rows()[0].spot),
"the walk must move, or comparing it proves nothing"
);
assert_eq!(served_first.1.step, served_second.1.step);
assert_eq!(served_first.1.spot, served_second.1.spot);
}
#[tokio::test]
async fn test_deleting_a_simulation_drops_its_shared_tape() {
let store = Arc::new(InMemorySimulationStore::new());
let shared = Arc::new(SharedTapes::default());
let manager = SimulationManager::new(
Arc::clone(&store) as Arc<dyn SimulationStore>,
SimulationV2Config::default(),
)
.with_shared_tapes(Arc::clone(&shared) as Arc<dyn SharedTapeCache>);
let created = match manager.create(parameters(3)).await {
Ok(created) => created,
Err(error) => panic!("{error}"),
};
let _ = manager.peek(created.id).await;
assert_eq!(shared.keys().len(), 1, "the tape must be shared first");
match manager.delete(created.id).await {
Ok(deleted) => assert!(deleted, "the simulation must have been there"),
Err(error) => panic!("{error}"),
}
assert!(
shared.keys().is_empty(),
"the deleted simulation left {:?} behind in the shared cache",
shared.keys()
);
}
#[tokio::test]
async fn test_only_one_instance_builds_a_tape() {
let store = Arc::new(InMemorySimulationStore::new());
let shared = Arc::new(SharedTapes::default());
let first = SimulationManager::new(
Arc::clone(&store) as Arc<dyn SimulationStore>,
SimulationV2Config::default(),
)
.with_shared_tapes(Arc::clone(&shared) as Arc<dyn SharedTapeCache>);
let second = SimulationManager::new(
Arc::clone(&store) as Arc<dyn SimulationStore>,
SimulationV2Config::default(),
)
.with_shared_tapes(Arc::clone(&shared) as Arc<dyn SharedTapeCache>);
let created = match first.create(parameters(4)).await {
Ok(created) => created,
Err(error) => panic!("the simulation must be created: {error}"),
};
let (one, two) = tokio::join!(first.peek(created.id), second.peek(created.id));
let one = match one {
Ok(snapshot) => snapshot,
Err(error) => panic!("the first instance must serve: {error}"),
};
let two = match two {
Ok(snapshot) => snapshot,
Err(error) => panic!("the second instance must serve: {error}"),
};
assert_eq!(
shared.writes(),
1,
"two instances wrote {} tapes for one simulation, so both built it",
shared.writes()
);
assert!(
shared.claims() >= 2,
"both instances must have asked for the build claim, got {}",
shared.claims()
);
assert_eq!(
one.1.spot, two.1.spot,
"the instance that waited must serve what the builder built"
);
}
#[tokio::test]
async fn test_an_abandoned_claim_does_not_block_a_build() {
let store = Arc::new(InMemorySimulationStore::new());
let shared = Arc::new(SharedTapes::default());
let manager = SimulationManager::new(
Arc::clone(&store) as Arc<dyn SimulationStore>,
SimulationV2Config::default(),
)
.with_shared_tapes(Arc::clone(&shared) as Arc<dyn SharedTapeCache>)
.with_shared_build_wait(Duration::from_millis(200));
let created = match manager.create(parameters(3)).await {
Ok(created) => created,
Err(error) => panic!("{error}"),
};
let key = tape_key(created.id, &created.parameters);
assert!(
matches!(shared.claim_build(&key).await, BuildClaim::Held(_)),
"the claim must be free first"
);
match manager.peek(created.id).await {
Ok(_) => {}
Err(error) => panic!("an abandoned claim must not stop a build: {error}"),
}
}
#[tokio::test]
async fn test_an_abandoned_owner_still_publishes_and_releases() {
let store = Arc::new(InMemorySimulationStore::new());
let shared = Arc::new(SharedTapes::default());
let manager = Arc::new(
SimulationManager::new(
Arc::clone(&store) as Arc<dyn SimulationStore>,
SimulationV2Config::default(),
)
.with_shared_tapes(Arc::clone(&shared) as Arc<dyn SharedTapeCache>),
);
let created = match manager.create(parameters(11)).await {
Ok(created) => created,
Err(error) => panic!("the simulation must be created: {error}"),
};
let owner = {
let manager = Arc::clone(&manager);
let id = created.id;
tokio::spawn(async move { manager.peek(id).await })
};
owner.abort();
match tokio::time::timeout(Duration::from_secs(5), manager.peek(created.id)).await {
Ok(Ok(_)) => {}
other => panic!("an abandoned owner must not strand the request after it: {other:?}"),
}
let held = match shared.building.lock() {
Ok(held) => held.len(),
Err(poisoned) => poisoned.into_inner().len(),
};
assert_eq!(held, 0, "the build claim must have been released");
assert_eq!(
shared.keys().len(),
1,
"the built tape must have been shared even though its caller left"
);
}
#[tokio::test]
async fn test_an_unreachable_shared_cache_degrades_to_building() {
let store = Arc::new(InMemorySimulationStore::new());
let manager = SimulationManager::new(
Arc::clone(&store) as Arc<dyn SimulationStore>,
SimulationV2Config::default(),
)
.with_shared_tapes(Arc::new(BrokenTapes) as Arc<dyn SharedTapeCache>);
let created = match manager.create(parameters(3)).await {
Ok(created) => created,
Err(error) => panic!("the simulation must be created: {error}"),
};
for step in 0..3 {
match manager.advance(created.id).await {
Ok(_) => {}
Err(error) => panic!("step {step} must serve with the cache down: {error}"),
}
}
}
#[tokio::test]
async fn test_the_shared_cache_keys_each_simulation_separately() {
let store = Arc::new(InMemorySimulationStore::new());
let shared = Arc::new(SharedTapes::default());
let manager = SimulationManager::new(
Arc::clone(&store) as Arc<dyn SimulationStore>,
SimulationV2Config::default(),
)
.with_shared_tapes(Arc::clone(&shared) as Arc<dyn SharedTapeCache>);
let one = match manager.create(parameters(3)).await {
Ok(created) => created,
Err(error) => panic!("{error}"),
};
let two = match manager.create(parameters(3)).await {
Ok(created) => created,
Err(error) => panic!("{error}"),
};
assert_ne!(one.id, two.id);
let _ = manager.advance(one.id).await;
let _ = manager.advance(two.id).await;
assert_eq!(
shared.keys().len(),
2,
"two simulations must occupy two entries, whatever their parameters: {:?}",
shared.keys()
);
}
#[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");
}
}