feature: Self-Service Clinical Testing Sessions
This commit is contained in:
@@ -8,15 +8,23 @@ public interface ISimulationRunner
|
||||
IReadOnlyList<SimulationRunState> ListRuns();
|
||||
SimulationRunState? GetRun(Guid runId);
|
||||
Task<SimulationRunState> StartAsync(
|
||||
string scenarioId, double speed, string startedByUserId, CancellationToken ct);
|
||||
string scenarioId, double speed, string startedByUserId, CancellationToken ct,
|
||||
string? sessionId = null);
|
||||
Task<SimulationSessionState> StartSessionAsync(
|
||||
string sessionId, double? speed, string userId, CancellationToken ct);
|
||||
bool Cancel(Guid runId);
|
||||
bool HasActiveRuns();
|
||||
void ClearRegistry();
|
||||
}
|
||||
|
||||
public sealed class SimulationRunner : ISimulationRunner, IHostedService
|
||||
{
|
||||
private static readonly TimeSpan SessionStagger = TimeSpan.FromMilliseconds(500);
|
||||
|
||||
private readonly ConcurrentDictionary<Guid, RunContext> _runs = new();
|
||||
private readonly ISimulationClientFactory _clientFactory;
|
||||
private readonly IScenarioCatalog _catalog;
|
||||
private readonly ISessionCatalog _sessions;
|
||||
private readonly IServiceScopeFactory _scopeFactory;
|
||||
private readonly SimulationOptions _options;
|
||||
private readonly ILogger<SimulationRunner> _logger;
|
||||
@@ -24,12 +32,14 @@ public sealed class SimulationRunner : ISimulationRunner, IHostedService
|
||||
public SimulationRunner(
|
||||
ISimulationClientFactory clientFactory,
|
||||
IScenarioCatalog catalog,
|
||||
ISessionCatalog sessions,
|
||||
IServiceScopeFactory scopeFactory,
|
||||
IOptions<SimulationOptions> options,
|
||||
ILogger<SimulationRunner> logger)
|
||||
{
|
||||
_clientFactory = clientFactory;
|
||||
_catalog = catalog;
|
||||
_sessions = sessions;
|
||||
_scopeFactory = scopeFactory;
|
||||
_options = options.Value;
|
||||
_logger = logger;
|
||||
@@ -44,8 +54,15 @@ public sealed class SimulationRunner : ISimulationRunner, IHostedService
|
||||
public SimulationRunState? GetRun(Guid runId) =>
|
||||
_runs.TryGetValue(runId, out var ctx) ? ctx.State.Snapshot() : null;
|
||||
|
||||
public bool HasActiveRuns() =>
|
||||
_runs.Values.Any(c =>
|
||||
c.State.Status is SimulationRunStatus.Pending or SimulationRunStatus.Running);
|
||||
|
||||
public void ClearRegistry() => _runs.Clear();
|
||||
|
||||
public async Task<SimulationRunState> StartAsync(
|
||||
string scenarioId, double speed, string startedByUserId, CancellationToken ct)
|
||||
string scenarioId, double speed, string startedByUserId, CancellationToken ct,
|
||||
string? sessionId = null)
|
||||
{
|
||||
if (!_options.Enabled)
|
||||
throw new ValidationException("Simulation is disabled.", "SIMULATION_DISABLED");
|
||||
@@ -68,34 +85,77 @@ public sealed class SimulationRunner : ISimulationRunner, IHostedService
|
||||
$"Maximum concurrent simulation runs ({_options.MaxConcurrentRuns}) reached.",
|
||||
"SIMULATION_CONCURRENCY_LIMIT");
|
||||
|
||||
var totalOffset = scenario.Events.Count == 0
|
||||
? 0
|
||||
: scenario.Events.Max(e => e.OffsetMinutes);
|
||||
return await RegisterAndStartAsync(scenario, speed, startedByUserId, sessionId, ct);
|
||||
}
|
||||
|
||||
var runId = Guid.NewGuid();
|
||||
var startedAt = DateTimeOffset.UtcNow;
|
||||
var state = new SimulationRunState
|
||||
public async Task<SimulationSessionState> StartSessionAsync(
|
||||
string sessionId, double? speed, string userId, CancellationToken ct)
|
||||
{
|
||||
if (!_options.Enabled)
|
||||
throw new ValidationException("Simulation is disabled.", "SIMULATION_DISABLED");
|
||||
|
||||
if (string.IsNullOrWhiteSpace(sessionId))
|
||||
throw new ValidationException("sessionId is required.", "SIMULATION_SESSION_REQUIRED");
|
||||
|
||||
var preset = _sessions.GetById(sessionId)
|
||||
?? throw new ValidationException(
|
||||
$"Unknown session '{sessionId}'.", "SIMULATION_SESSION_UNKNOWN");
|
||||
|
||||
var resolvedSpeed = speed ?? preset.DefaultSpeed;
|
||||
if (resolvedSpeed <= 0 || resolvedSpeed > _options.MaxSpeed)
|
||||
throw new ValidationException(
|
||||
$"speed must be > 0 and <= {_options.MaxSpeed}.", "SIMULATION_SPEED_INVALID");
|
||||
|
||||
// Resolve every scenario up front so a typo never half-starts a session.
|
||||
var scenarios = new List<ScenarioFile>(preset.Scenarios.Count);
|
||||
foreach (var id in preset.Scenarios)
|
||||
{
|
||||
RunId = runId,
|
||||
ScenarioId = scenario.Scenario.Id,
|
||||
ScenarioName = scenario.Scenario.Name,
|
||||
Speed = speed,
|
||||
StartedByUserId = startedByUserId,
|
||||
var scenario = _catalog.GetById(id)
|
||||
?? throw new ValidationException(
|
||||
$"Unknown scenario '{id}' in session '{preset.Id}'.",
|
||||
"SIMULATION_SCENARIO_UNKNOWN");
|
||||
scenarios.Add(scenario);
|
||||
}
|
||||
|
||||
// All-or-nothing admission — check capacity before starting anything.
|
||||
var activeCount = _runs.Values.Count(c =>
|
||||
c.State.Status is SimulationRunStatus.Pending or SimulationRunStatus.Running);
|
||||
if (activeCount + scenarios.Count > _options.MaxConcurrentRuns)
|
||||
throw new ConflictException(
|
||||
$"Session '{preset.Id}' needs {scenarios.Count} runs but only " +
|
||||
$"{_options.MaxConcurrentRuns - activeCount} slot(s) remain " +
|
||||
$"(max {_options.MaxConcurrentRuns}).",
|
||||
"SIMULATION_CONCURRENCY_LIMIT");
|
||||
|
||||
var startedAt = DateTimeOffset.UtcNow;
|
||||
var started = new List<SimulationRunState>(scenarios.Count);
|
||||
|
||||
try
|
||||
{
|
||||
for (var i = 0; i < scenarios.Count; i++)
|
||||
{
|
||||
if (i > 0)
|
||||
await Task.Delay(SessionStagger, ct);
|
||||
|
||||
var state = await RegisterAndStartAsync(
|
||||
scenarios[i], resolvedSpeed, userId, preset.Id, ct);
|
||||
started.Add(state);
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
foreach (var run in started)
|
||||
Cancel(run.RunId);
|
||||
throw;
|
||||
}
|
||||
|
||||
return new SimulationSessionState
|
||||
{
|
||||
SessionId = preset.Id,
|
||||
Name = preset.Name,
|
||||
StartedAt = startedAt,
|
||||
TotalOffsetMinutes = totalOffset,
|
||||
PatientDisplayName = $"{scenario.Patient.FirstName} {scenario.Patient.LastName}",
|
||||
RunIds = started.Select(s => s.RunId).ToList(),
|
||||
};
|
||||
|
||||
await PersistNewRunAsync(state, ct);
|
||||
|
||||
var cts = new CancellationTokenSource();
|
||||
var ctx = new RunContext(state, cts, scenario);
|
||||
if (!_runs.TryAdd(runId, ctx))
|
||||
throw new ConflictException("Failed to register simulation run.", "SIMULATION_REGISTER_FAILED");
|
||||
|
||||
_ = Task.Run(() => ExecuteAsync(ctx, CancellationToken.None), CancellationToken.None);
|
||||
|
||||
return state.Snapshot();
|
||||
}
|
||||
|
||||
public bool Cancel(Guid runId)
|
||||
@@ -131,6 +191,44 @@ public sealed class SimulationRunner : ISimulationRunner, IHostedService
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<SimulationRunState> RegisterAndStartAsync(
|
||||
ScenarioFile scenario,
|
||||
double speed,
|
||||
string startedByUserId,
|
||||
string? sessionId,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var totalOffset = scenario.Events.Count == 0
|
||||
? 0
|
||||
: scenario.Events.Max(e => e.OffsetMinutes);
|
||||
|
||||
var runId = Guid.NewGuid();
|
||||
var startedAt = DateTimeOffset.UtcNow;
|
||||
var state = new SimulationRunState
|
||||
{
|
||||
RunId = runId,
|
||||
ScenarioId = scenario.Scenario.Id,
|
||||
ScenarioName = scenario.Scenario.Name,
|
||||
Speed = speed,
|
||||
SessionId = sessionId,
|
||||
StartedByUserId = startedByUserId,
|
||||
StartedAt = startedAt,
|
||||
TotalOffsetMinutes = totalOffset,
|
||||
PatientDisplayName = $"{scenario.Patient.FirstName} {scenario.Patient.LastName}",
|
||||
};
|
||||
|
||||
await PersistNewRunAsync(state, ct);
|
||||
|
||||
var cts = new CancellationTokenSource();
|
||||
var ctx = new RunContext(state, cts, scenario);
|
||||
if (!_runs.TryAdd(runId, ctx))
|
||||
throw new ConflictException("Failed to register simulation run.", "SIMULATION_REGISTER_FAILED");
|
||||
|
||||
_ = Task.Run(() => ExecuteAsync(ctx, CancellationToken.None), CancellationToken.None);
|
||||
|
||||
return state.Snapshot();
|
||||
}
|
||||
|
||||
private async Task ExecuteAsync(RunContext ctx, CancellationToken _)
|
||||
{
|
||||
var runId = ctx.State.RunId;
|
||||
@@ -198,6 +296,7 @@ public sealed class SimulationRunner : ISimulationRunner, IHostedService
|
||||
ScenarioName = state.ScenarioName,
|
||||
Speed = state.Speed,
|
||||
Status = SimulationRunStatus.Pending,
|
||||
SessionId = state.SessionId,
|
||||
StartedByUserId = state.StartedByUserId,
|
||||
StartedAt = state.StartedAt,
|
||||
TotalOffsetMinutes = state.TotalOffsetMinutes,
|
||||
@@ -221,6 +320,7 @@ public sealed class SimulationRunner : ISimulationRunner, IHostedService
|
||||
row.Status = status;
|
||||
row.PatientId = state.PatientId;
|
||||
row.EncounterId = state.EncounterId;
|
||||
row.SessionId = state.SessionId;
|
||||
row.ObservationsSent = state.ObservationsSent;
|
||||
row.MedicationsSent = state.MedicationsSent;
|
||||
row.OrdersPlaced = state.OrdersPlaced;
|
||||
|
||||
Reference in New Issue
Block a user