VehicleFleetSimulator source
This page is part of the documentation for Orleans.Lattice 9.9.0 (release line 9.9), built 2026-10-04. It is also published as markdown, with every table and list, at source.md, and llms.txt lists every page.The source of the VehicleFleetSimulator sample.
src/VehicleFleetSimulator.Api/Program.cs
using System.Collections.Immutable;
using Azure.Data.Tables;
using Microsoft.AspNetCore.Mvc;
using Orleans.Configuration;
using VehicleFleetSimulator.Abstractions;
using VehicleFleetSimulator.Api.Services;
using VehicleFleetSimulator.Api.Streams;
var builder = WebApplication.CreateBuilder(args);
var clusterConnection = builder.Configuration["Persistence:ConnectionString"]
?? builder.Configuration["Orleans:ClusterConnectionString"]
?? "UseDevelopmentStorage=true";
builder.Host.UseOrleansClient(client =>
{
client.UseAzureStorageClustering(options =>
{
options.TableServiceClient = new TableServiceClient(clusterConnection);
});
client.Configure<ClusterOptions>(opts =>
{
opts.ClusterId = builder.Configuration["Orleans:ClusterId"] ?? "vfs-dev";
opts.ServiceId = builder.Configuration["Orleans:ServiceId"] ?? "VehicleFleetSimulator";
});
});
builder.Services.AddProblemDetails();
builder.Services.AddEndpointsApiExplorer();
builder.Services.AddSwaggerGen(c =>
{
c.SwaggerDoc("v1", new() { Title = "Vehicle Fleet Simulator API", Version = "v1" });
});
// Milestone 6: gRPC stream consumer surface.
builder.Services.AddSingleton<IFleetStreamHub, FleetStreamHub>();
builder.Services.AddHostedService<TelemetryFanOutService>();
builder.Services.AddSingleton<RecordingService>();
builder.Services.AddHostedService(sp => sp.GetRequiredService<RecordingService>());
builder.Services.AddSingleton<SimulationEventBroadcaster>();
builder.Services.AddGrpc(options =>
{
options.Interceptors.Add<ApiKeyInterceptor>();
});
builder.Services.AddGrpcReflection();
builder.Services.AddSingleton<ApiKeyInterceptor>();
builder.Services.AddSingleton<ApiKeyEndpointFilter>();
// Browser clients (Blazor WASM UI) need CORS + gRPC-Web. The exposed headers are required so
// trailers and the dropped-count signal survive the gRPC-Web translation layer.
const string BrowserCorsPolicy = "BrowserClients";
builder.Services.AddCors(options =>
{
options.AddPolicy(BrowserCorsPolicy, policy =>
{
policy
.SetIsOriginAllowed(_ => true)
.AllowAnyHeader()
.AllowAnyMethod()
.WithExposedHeaders("Grpc-Status", "Grpc-Message", "Grpc-Encoding", "Grpc-Accept-Encoding", "dropped-count");
});
});
var app = builder.Build();
app.UseStatusCodePages();
app.UseExceptionHandler();
app.UseSwagger();
app.UseSwaggerUI(c => c.SwaggerEndpoint("/swagger/v1/swagger.json", "Vehicle Fleet Simulator API v1"));
app.UseCors(BrowserCorsPolicy);
app.UseGrpcWeb(new GrpcWebOptions { DefaultEnabled = true });
app.MapGrpcService<FleetStreamService>().EnableGrpcWeb().RequireCors(BrowserCorsPolicy);
if (app.Environment.IsDevelopment())
{
app.MapGrpcReflectionService();
}
app.MapGet("/", () => Results.Redirect("/swagger")).ExcludeFromDescription();
// Milestone 0: round-trip ping through the Orleans cluster.
app.MapGet("/api/ping/{key}", async (string key, IGrainFactory grains, string? message) =>
{
var grain = grains.GetGrain<IPingGrain>(key);
var reply = await grain.Ping(message ?? "hello");
return Results.Ok(new { key, reply });
}).WithTags("Diagnostics");
static IFleetGrain Fleet(IGrainFactory grains) => grains.GetGrain<IFleetGrain>(IFleetGrain.Key);
static ICityGraphGrain CityGraph(IGrainFactory grains) => grains.GetGrain<ICityGraphGrain>(ICityGraphGrain.Key);
static ISimulationConfigGrain SimConfig(IGrainFactory grains) => grains.GetGrain<ISimulationConfigGrain>(ISimulationConfigGrain.Key);
static ProblemDetails BadRequest(string? detail) => new()
{
Status = StatusCodes.Status400BadRequest,
Title = "Invalid request",
Detail = detail,
};
static ProblemDetails Conflict(string? detail) => new()
{
Status = StatusCodes.Status409Conflict,
Title = "Conflict",
Detail = detail,
};
// All /api/* endpoints sit behind an API-key endpoint filter that mirrors the gRPC
// ApiKeyInterceptor (no-op when Auth:ApiKey is unconfigured, e.g. local dev). The /api/ping/{key}
// liveness probe above is intentionally left outside this group so health checks aren't gated on
// credentials.
var api = app.MapGroup("/api").AddEndpointFilter<ApiKeyEndpointFilter>();
// ─── Vehicles ────────────────────────────────────────────────────────────────
var vehicles = api.MapGroup("/vehicles").WithTags("Vehicles");
vehicles.MapPost("/", async (VehicleSpec spec, IGrainFactory grains) =>
{
if (spec is null)
return Results.Problem(BadRequest("Body is required."));
if (spec.Route is { } r && !r.IsDefault && r.Length > 0)
{
if (r.Length < 2)
return Results.Problem(BadRequest("Route must contain at least two cities."));
var graph = await CityGraph(grains).GetGraph();
if (!IsValidRoute(graph, r, out var routeError))
return Results.Problem(BadRequest(routeError));
}
if (spec.Config is { } cfg && !TryValidateVehicleConfig(cfg, out var cfgError))
return Results.Problem(BadRequest(cfgError));
try
{
var id = await Fleet(grains).AddVehicle(spec);
return Results.Created($"/api/vehicles/{id}", new { vehicleId = id });
}
catch (InvalidOperationException ex)
{
return Results.Problem(Conflict(ex.Message));
}
catch (ArgumentException ex)
{
return Results.Problem(BadRequest(ex.Message));
}
});
vehicles.MapPost("/batch", async (VehicleSpec[] specs, IGrainFactory grains) =>
{
if (specs is null || specs.Length == 0)
return Results.Problem(BadRequest("Batch must contain at least one spec."));
var graph = await CityGraph(grains).GetGraph();
foreach (var spec in specs)
{
if (spec.Route is { } r && !r.IsDefault && r.Length > 0)
{
if (r.Length < 2)
return Results.Problem(BadRequest("Invalid route in batch: must contain at least two cities."));
if (!IsValidRoute(graph, r, out var routeError))
return Results.Problem(BadRequest($"Invalid route in batch: {routeError}"));
}
if (spec.Config is { } cfg && !TryValidateVehicleConfig(cfg, out var cfgError))
return Results.Problem(BadRequest($"Invalid config in batch: {cfgError}"));
}
try
{
var ids = await Fleet(grains).AddVehicleBatch(specs);
return Results.Ok(new { count = ids.Count, vehicleIds = ids });
}
catch (InvalidOperationException ex)
{
return Results.Problem(Conflict(ex.Message));
}
});
vehicles.MapGet("/", async (
IGrainFactory grains,
[FromQuery] VehicleStatus? status,
[FromQuery] string? routeContains,
[FromQuery] int? skip,
[FromQuery] int? take) =>
{
var ids = await Fleet(grains).ListVehicles();
var snapshots = await Task.WhenAll(
ids.Select(id => grains.GetGrain<IVehicleGrain>(id).GetSnapshot().AsTask()));
IEnumerable<VehicleSnapshot> filtered = snapshots.OfType<VehicleSnapshot>();
if (status is { } s)
filtered = filtered.Where(v => v.Status == s);
if (!string.IsNullOrWhiteSpace(routeContains))
filtered = filtered.Where(v => v.Route.Contains(routeContains, StringComparer.OrdinalIgnoreCase));
var list = filtered.ToArray();
var total = list.Length;
var skipN = Math.Max(0, skip ?? 0);
var takeN = Math.Clamp(take ?? 100, 1, 1000);
var page = list.Skip(skipN).Take(takeN).ToArray();
return Results.Ok(new { total, skip = skipN, take = takeN, count = page.Length, vehicles = page });
});
vehicles.MapGet("/{id:guid}", async (Guid id, IGrainFactory grains) =>
{
var snapshot = await grains.GetGrain<IVehicleGrain>(id).GetSnapshot();
return snapshot is null ? Results.NotFound() : Results.Ok(snapshot);
});
vehicles.MapDelete("/{id:guid}", async (Guid id, IGrainFactory grains) =>
{
var removed = await Fleet(grains).RemoveVehicle(id);
return removed ? Results.NoContent() : Results.NotFound();
});
vehicles.MapDelete("/", async (IGrainFactory grains) =>
{
// Snapshot the roster, clear it (cheap grain call), then fan out per-vehicle Stop() calls
// from the API host with bounded concurrency. Blocking here lets the caller observe true
// completion without bumping into Orleans' default per-grain-call response timeout.
var ids = await Fleet(grains).ListVehicles();
await Fleet(grains).RemoveAllVehicles();
if (ids.Count == 0)
return Results.Ok(new { removed = 0, stopped = 0 });
using var throttle = new SemaphoreSlim(32);
int stopped = 0;
var tasks = ids.Select(async id =>
{
await throttle.WaitAsync();
try
{
await grains.GetGrain<IVehicleGrain>(id).Clear();
Interlocked.Increment(ref stopped);
}
catch
{
// Grain may already be deactivated or unreachable; the roster is already cleared.
}
finally
{
throttle.Release();
}
});
await Task.WhenAll(tasks);
return Results.Ok(new { removed = ids.Count, stopped });
});
vehicles.MapPost("/{id:guid}/route", async (Guid id, AssignRouteRequest body, IGrainFactory grains) =>
{
if (body is null || body.Route.IsDefault || body.Route.Length < 2)
return Results.Problem(BadRequest("Route must contain at least two cities."));
var graph = await CityGraph(grains).GetGraph();
if (!IsValidRoute(graph, body.Route, out var routeError))
return Results.Problem(BadRequest(routeError));
var snapshot = await grains.GetGrain<IVehicleGrain>(id).GetSnapshot();
if (snapshot is null) return Results.NotFound();
try
{
await grains.GetGrain<IVehicleGrain>(id).SetRoute(body.Route);
return Results.NoContent();
}
catch (ArgumentException ex)
{
return Results.Problem(BadRequest(ex.Message));
}
catch (InvalidOperationException ex)
{
return Results.Problem(Conflict(ex.Message));
}
});
vehicles.MapPost("/{id:guid}/start", async (Guid id, IGrainFactory grains) =>
{
try
{
await grains.GetGrain<IVehicleGrain>(id).Start();
return Results.NoContent();
}
catch (InvalidOperationException ex)
{
return Results.Problem(Conflict(ex.Message));
}
});
vehicles.MapPost("/{id:guid}/stop", async (Guid id, IGrainFactory grains) =>
{
await grains.GetGrain<IVehicleGrain>(id).Stop();
return Results.NoContent();
});
// ─── Cities ──────────────────────────────────────────────────────────────────
var cities = api.MapGroup("/cities").WithTags("Cities");
cities.MapGet("/", async (IGrainFactory grains) =>
{
var graph = await CityGraph(grains).GetGraph();
return Results.Ok(new { cities = graph.Cities, edges = graph.Edges, positionOverrides = graph.PositionOverrides });
});
cities.MapPost("/{id}/position", async (string id, CityPosition body, IGrainFactory grains, SimulationEventBroadcaster bus) =>
{
if (body is null) return Results.Problem(BadRequest("Body is required."));
if (!double.IsFinite(body.X) || !double.IsFinite(body.Y))
return Results.Problem(BadRequest("X and Y must be finite numbers."));
var ok = await CityGraph(grains).SetCityPosition(id, body.X, body.Y);
if (!ok) return Results.NotFound();
bus.PublishCityMoved(id, body.X, body.Y);
return Results.NoContent();
});
cities.MapDelete("/positions", async (IGrainFactory grains) =>
{
await CityGraph(grains).ClearCityPositions();
return Results.NoContent();
});
// ─── Fleet ───────────────────────────────────────────────────────────────────
var fleet = api.MapGroup("/fleet").WithTags("Fleet");
fleet.MapGet("/stats", async (IGrainFactory grains) =>
{
var stats = await Fleet(grains).GetFleetStats();
return Results.Ok(stats);
});
// ─── Bulk fleet operations (operate on every persisted vehicle) ──────────────
vehicles.MapPost("/start-all", async (IGrainFactory grains) =>
{
var started = await Fleet(grains).StartAllVehicles();
return Results.Ok(new { started });
});
vehicles.MapPost("/stop-all", async (IGrainFactory grains) =>
{
var stopped = await Fleet(grains).StopAllVehicles();
return Results.Ok(new { stopped });
});
vehicles.MapPost("/{id:guid}/fault", async (Guid id, FaultRequest body, IGrainFactory grains) =>
{
if (body is null) return Results.Problem(BadRequest("Body is required."));
if (!Enum.IsDefined(body.Fault)) return Results.Problem(BadRequest($"Unknown fault kind '{body.Fault}'."));
var snapshot = await grains.GetGrain<IVehicleGrain>(id).GetSnapshot();
if (snapshot is null) return Results.NotFound();
var applied = await grains.GetGrain<IVehicleGrain>(id).InjectFault(body.Fault);
return applied ? Results.Ok(new { fault = body.Fault.ToString() }) : Results.Problem(Conflict("Fault could not be applied."));
});
// ─── Simulation control (pause / resume) + config ────────────────────────────
var sim = api.MapGroup("/simulation").WithTags("Simulation");
sim.MapPost("/pause", async (IGrainFactory grains, SimulationEventBroadcaster bus) =>
{
var cfg = await SimConfig(grains).UpdateConfig(new SimulationConfigPatch(IsPaused: true));
bus.PublishConfigChanged(cfg);
return Results.Ok(cfg);
});
sim.MapPost("/resume", async (IGrainFactory grains, SimulationEventBroadcaster bus) =>
{
var cfg = await SimConfig(grains).UpdateConfig(new SimulationConfigPatch(IsPaused: false));
bus.PublishConfigChanged(cfg);
return Results.Ok(cfg);
});
// SSE feed of small "things changed" pings -- the UI uses this to live-refresh the Control
// flyout without polling. Intentionally kept as a single feed (config + city moves) because the
// payload is tiny and clients don't have to fan-out subscribe; if richer typed events are
// needed in the future, switch each line to a typed `event:` field instead of just `data:`.
api.MapGet("/events/stream", async (HttpContext http, SimulationEventBroadcaster bus, CancellationToken ct) =>
{
http.Response.Headers.ContentType = "text/event-stream";
http.Response.Headers.CacheControl = "no-cache";
http.Response.Headers["X-Accel-Buffering"] = "no";
await bus.WriteToAsync(http.Response, ct);
}).WithTags("Simulation").ExcludeFromDescription();
// ─── Scenario presets ────────────────────────────────────────────────────────
var scenarios = api.MapGroup("/scenarios").WithTags("Scenarios");
scenarios.MapGet("/", () => Results.Ok(new { scenarios = ScenarioCatalog.All }));
scenarios.MapPost("/{name}", async (string name, IGrainFactory grains) =>
{
if (!ScenarioCatalog.TryGet(name, out var preset))
return Results.NotFound();
if (preset.StartCityId is { } start)
{
var graph = await CityGraph(grains).GetGraph();
if (!graph.Cities.Any(c => string.Equals(c.Id, start, StringComparison.OrdinalIgnoreCase)))
return Results.Problem(BadRequest($"Scenario '{name}' references unknown start city '{start}'."));
}
if (preset.ResetFleetFirst)
{
var existingIds = await Fleet(grains).ListVehicles();
await Fleet(grains).RemoveAllVehicles();
// Best-effort fan-out clear so grain timers stop promptly.
using var throttle = new SemaphoreSlim(32);
await Task.WhenAll(existingIds.Select(async id =>
{
await throttle.WaitAsync();
try { await grains.GetGrain<IVehicleGrain>(id).Clear(); }
catch { /* roster already cleared */ }
finally { throttle.Release(); }
}));
}
var specs = new VehicleSpec[preset.VehicleCount];
for (int i = 0; i < specs.Length; i++)
specs[i] = new VehicleSpec(VehicleId: null, StartCityId: preset.StartCityId);
var ids = await Fleet(grains).AddVehicleBatch(specs);
return Results.Ok(new { name = preset.Name, count = ids.Count, vehicleIds = ids });
});
// ─── Diagnostics: per-shard fan-out observer counts ──────────────────────────
api.MapGet("/diagnostics/fanout", async (IGrainFactory grains) =>
{
var shards = new List<object>(StreamConstants.TelemetryAllShardCount);
for (var i = 0; i < StreamConstants.TelemetryAllShardCount; i++)
{
var grain = grains.GetGrain<IFleetFanOutGrain>(IFleetFanOutGrain.ShardKey(i));
var diag = await grain.GetDiagnostics();
shards.Add(new { shard = i, observerCount = diag.ObserverCount, publishedCount = diag.PublishedCount });
}
var eventsGrain = grains.GetGrain<IFleetFanOutGrain>(IFleetFanOutGrain.EventsKey());
var eventsDiag = await eventsGrain.GetDiagnostics();
return Results.Ok(new
{
telemetryShards = shards,
eventsActivation = new { observerCount = eventsDiag.ObserverCount, publishedCount = eventsDiag.PublishedCount },
});
}).WithTags("Diagnostics");
// ─── Recording ───────────────────────────────────────────────────────────────
var recording = api.MapGroup("/recording").WithTags("Recording");
recording.MapPost("/start", (RecordingService rec, [FromQuery] int? capacity) =>
{
var id = rec.Start(capacity ?? 100_000);
return Results.Ok(new { id, capacity = capacity ?? 100_000 });
});
recording.MapPost("/{id:guid}/stop", (Guid id, RecordingService rec) =>
{
var summary = rec.Stop(id);
return summary is null ? Results.NotFound() : Results.Ok(summary);
});
recording.MapGet("/", (RecordingService rec) => Results.Ok(new { recordings = rec.List() }));
recording.MapGet("/{id:guid}", (Guid id, RecordingService rec) =>
{
var dump = rec.Get(id);
return dump is null ? Results.NotFound() : Results.Ok(dump);
});
recording.MapPost("/{id:guid}/replay", async (Guid id, RecordingService rec, IGrainFactory grains) =>
{
var dump = rec.Get(id);
if (dump is null) return Results.NotFound();
// Replay is intentionally simple: spawn one fresh vehicle per distinct vehicle id observed in
// the recording, starting from each vehicle's first telemetry city. The sim drives them with
// newly-generated routes from there -- this is "re-create the same scene", not "re-emit the
// same ticks". A true tick-replay would require a non-driving VehicleGrain mode, which is
// a bigger lift; this lighter shape is sufficient for the demo flow.
var distinct = dump.Telemetry
.GroupBy(t => t.VehicleId)
.Select(g => g.OrderBy(t => t.TimestampUtc).First())
.ToArray();
var specs = distinct.Select(t => new VehicleSpec(VehicleId: null, StartCityId: t.FromCityId)).ToArray();
var ids = specs.Length == 0 ? Array.Empty<Guid>() : (await Fleet(grains).AddVehicleBatch(specs)).ToArray();
return Results.Ok(new { source = id, replayed = ids.Length, vehicleIds = ids });
});
// ─── Simulation config ───────────────────────────────────────────────────────
var simCfg = api.MapGroup("/config/simulation").WithTags("Configuration");
simCfg.MapGet("/", async (IGrainFactory grains) =>
{
var cfg = await SimConfig(grains).GetConfig();
return Results.Ok(cfg);
});
simCfg.MapPut("/", async (SimulationConfigPatch patch, IGrainFactory grains, SimulationEventBroadcaster bus) =>
{
if (patch is null) return Results.Problem(BadRequest("Body is required."));
if (patch.TickInterval is { } t && t <= TimeSpan.Zero)
return Results.Problem(BadRequest("TickInterval must be positive."));
if (patch.DefaultVehicleConfig is { } cfg && !TryValidateVehicleConfig(cfg, out var cfgError))
return Results.Problem(BadRequest(cfgError));
if (patch.TimeScale is { } ts && (!double.IsFinite(ts) || ts <= 0 || ts > 10000))
return Results.Problem(BadRequest("TimeScale must be a positive finite number ≤ 10000."));
try
{
var updated = await SimConfig(grains).UpdateConfig(patch);
bus.PublishConfigChanged(updated);
return Results.Ok(updated);
}
catch (ArgumentException ex)
{
return Results.Problem(BadRequest(ex.Message));
}
});
app.Run();
static bool IsValidRoute(CityGraphSnapshot graph, ImmutableArray<string> route, out string? error)
{
var cityIds = graph.Cities.Select(c => c.Id).ToHashSet(StringComparer.Ordinal);
var edgeSet = new HashSet<(string, string)>();
foreach (var e in graph.Edges)
{
edgeSet.Add((e.FromCityId, e.ToCityId));
edgeSet.Add((e.ToCityId, e.FromCityId));
}
for (int i = 0; i < route.Length; i++)
{
if (!cityIds.Contains(route[i]))
{
error = $"Unknown city '{route[i]}'.";
return false;
}
if (i > 0)
{
if (route[i] == route[i - 1])
{
error = $"Consecutive duplicate city '{route[i]}'.";
return false;
}
if (!edgeSet.Contains((route[i - 1], route[i])))
{
error = $"No road between '{route[i - 1]}' and '{route[i]}'.";
return false;
}
}
}
error = null;
return true;
}
static bool TryValidateVehicleConfig(VehicleConfig cfg, out string? error)
{
if (cfg.MinSpeedKph < 0) { error = "MinSpeedKph must be non-negative."; return false; }
if (cfg.MaxSpeedKph <= cfg.MinSpeedKph) { error = "MaxSpeedKph must be greater than MinSpeedKph."; return false; }
if (cfg.FuelCapacityLitres <= 0) { error = "FuelCapacityLitres must be positive."; return false; }
if (cfg.LitresPerKmAtOptimal <= 0) { error = "LitresPerKmAtOptimal must be positive."; return false; }
if (cfg.RefuelDelay < TimeSpan.Zero) { error = "RefuelDelay must be non-negative."; return false; }
if (cfg.SpeedSmoothingAlpha is < 0 or > 1) { error = "SpeedSmoothingAlpha must be in [0,1]."; return false; }
if (cfg.SpeedResampleInterval <= TimeSpan.Zero) { error = "SpeedResampleInterval must be positive."; return false; }
error = null;
return true;
}
public sealed record AssignRouteRequest(ImmutableArray<string> Route);
public sealed record FaultRequest(VehicleFault Fault);
src/VehicleFleetSimulator.Silo/Program.cs
using Azure.Data.Tables;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Diagnostics.HealthChecks;
using Microsoft.Extensions.Hosting;
using Orleans.Configuration;
using VehicleFleetSimulator.Abstractions;
using VehicleFleetSimulator.Grains;
using VehicleFleetSimulator.Grains.Cities;
using VehicleFleetSimulator.Grains.Telemetry;
var builder = Host.CreateApplicationBuilder(args);
// City graph: bind from "Cities" section and register a singleton provider.
builder.Services.Configure<CityGraphOptions>(builder.Configuration.GetSection("Cities"));
builder.Services.AddSingleton<ICityGraphProvider>(sp =>
{
var opts = sp.GetRequiredService<Microsoft.Extensions.Options.IOptions<CityGraphOptions>>().Value;
return new StaticCityGraphProvider(opts.BuildGraph());
});
// Determinism mode: ambient TimeProvider so grains and the simulator can be driven by tests.
builder.Services.AddSingleton(TimeProvider.System);
// Telemetry sink seam: VehicleGrain publishes through ITelemetrySink. The default
// FanOutTelemetrySink preserves the original direct-cross-grain dispatch to IFleetFanOutGrain;
// alternative sinks (NullTelemetrySink, future LatticeSink) can be swapped in for benchmark runs.
builder.Services.AddSingleton<ITelemetrySink, FanOutTelemetrySink>();
// Silo-scoped runtime state: shared in-process between SimulationConfigGrain (writer) and every
// VehicleGrain (reader) so slider changes propagate to thousands of grains in the time it takes
// a volatile field to flush - no polling, no streams, no per-tick grain calls.
builder.Services.AddSingleton<SimulationRuntimeState>();
var azuriteConnection = builder.Configuration["Persistence:ConnectionString"] ?? "UseDevelopmentStorage=true";
var clusterId = builder.Configuration["Orleans:ClusterId"] ?? "vfs-dev";
var serviceId = builder.Configuration["Orleans:ServiceId"] ?? "VehicleFleetSimulator";
builder.UseOrleans(silo =>
{
silo.Configure<ClusterOptions>(opts =>
{
opts.ClusterId = clusterId;
opts.ServiceId = serviceId;
});
// M7: Azure Storage clustering and reminders on Azurite; grain state stays in memory and no streams are configured.
silo.UseAzureStorageClustering(options =>
{
options.TableServiceClient = new TableServiceClient(azuriteConnection);
});
silo.AddMemoryGrainStorageAsDefault();
silo.UseAzureTableReminderService(options =>
{
options.TableServiceClient = new TableServiceClient(azuriteConnection);
});
// No streams: VehicleGrain dispatches directly to IFleetFanOutGrain via cross-grain calls.
// This avoids the memory-stream pulling agent, queue cache, and 1 MB FixedSizeBuffer LOH
// segments that previously dominated silo working set under load.
});
// M7: Health checks for Azurite Tables (still used for clustering + reminders).
builder.Services.AddHealthChecks()
.AddCheck("azurite-tables", new AzuriteTableHealthCheck(azuriteConnection), tags: ["ready"]);
await builder.Build().RunAsync();
internal sealed class AzuriteTableHealthCheck(string connectionString) : IHealthCheck
{
public async Task<HealthCheckResult> CheckHealthAsync(HealthCheckContext context, CancellationToken cancellationToken = default)
{
try
{
var client = new TableServiceClient(connectionString);
await foreach (var _ in client.QueryAsync(maxPerPage: 1, cancellationToken: cancellationToken))
break;
return HealthCheckResult.Healthy();
}
catch (Exception ex)
{
return HealthCheckResult.Unhealthy("Azurite Table service unreachable", ex);
}
}
}
src/VehicleFleetSimulator.Ui/Program.cs
using Microsoft.AspNetCore.Components.Web;
using Microsoft.AspNetCore.Components.WebAssembly.Hosting;
using VehicleFleetSimulator.Ui;
using VehicleFleetSimulator.Ui.Services;
var builder = WebAssemblyHostBuilder.CreateDefault(args);
builder.RootComponents.Add<App>("#app");
builder.RootComponents.Add<HeadOutlet>("head::after");
var apiBase = builder.Configuration["Api:BaseAddress"]
?? builder.HostEnvironment.BaseAddress;
builder.Services.AddSingleton(new Uri(apiBase));
builder.Services.AddSingleton<FleetState>();
builder.Services.AddSingleton<SimulationConfigClient>(sp => new SimulationConfigClient(
sp.GetRequiredService<Uri>()));
builder.Services.AddSingleton<FleetAdminClient>(sp => new FleetAdminClient(
sp.GetRequiredService<Uri>()));
builder.Services.AddSingleton<FleetStreamClient>(sp => new FleetStreamClient(
sp.GetRequiredService<Uri>(),
sp.GetRequiredService<FleetState>(),
sp.GetRequiredService<ILoggerFactory>().CreateLogger<FleetStreamClient>()));
await builder.Build().RunAsync();
docker-compose.yml
# Vehicle Fleet Simulator - full local stack (Azurite + Silo + Api + Ui).
#
# Usage:
# docker compose up --build
# docker compose down -v # also clears Azurite persisted state
#
# The Silo and Api both reach Azurite via the "azurite" service hostname; the
# UseDevelopmentStorage=true shortcut targets 127.0.0.1, so we set explicit
# endpoints in the connection-string env vars below. The Ui is a Blazor WASM
# SPA served by nginx - it talks to the Api from the user's browser at
# http://localhost:8080 (the host-side mapping), not over the compose network.
services:
azurite:
image: mcr.microsoft.com/azure-storage/azurite:latest
container_name: vfs-azurite
command:
- "azurite"
- "--blobHost=0.0.0.0"
- "--queueHost=0.0.0.0"
- "--tableHost=0.0.0.0"
- "--location=/data"
- "--skipApiVersionCheck"
- "--silent"
ports:
- "10000:10000" # blob
- "10001:10001" # queue
- "10002:10002" # table
volumes:
- azurite-data:/data
healthcheck:
test: ["CMD", "nc", "-z", "127.0.0.1", "10002"]
interval: 5s
timeout: 3s
retries: 20
silo:
build:
context: .
dockerfile: src/VehicleFleetSimulator.Silo/Dockerfile
container_name: vfs-silo
depends_on:
azurite:
condition: service_healthy
environment:
DOTNET_ENVIRONMENT: Production
Orleans__ClusterId: vfs-dev
Orleans__ServiceId: VehicleFleetSimulator
Persistence__ConnectionString: "DefaultEndpointsProtocol=http;AccountName=devstoreaccount1;AccountKey=Eby8vdM02xNOcqFlqUwJPLlmEtlCDXJ1OUzFT50uSRZ6IFsuFq2UVErCz4I6tq/K1SZFPTOtr/KBHBeksoGMGw==;BlobEndpoint=http://azurite:10000/devstoreaccount1;QueueEndpoint=http://azurite:10001/devstoreaccount1;TableEndpoint=http://azurite:10002/devstoreaccount1;"
Streams__ConnectionString: "DefaultEndpointsProtocol=http;AccountName=devstoreaccount1;AccountKey=Eby8vdM02xNOcqFlqUwJPLlmEtlCDXJ1OUzFT50uSRZ6IFsuFq2UVErCz4I6tq/K1SZFPTOtr/KBHBeksoGMGw==;QueueEndpoint=http://azurite:10001/devstoreaccount1;TableEndpoint=http://azurite:10002/devstoreaccount1;"
expose:
- "11111"
- "30000"
api:
build:
context: .
dockerfile: src/VehicleFleetSimulator.Api/Dockerfile
container_name: vfs-api
depends_on:
azurite:
condition: service_healthy
silo:
condition: service_started
environment:
ASPNETCORE_ENVIRONMENT: Development
Orleans__ClusterId: vfs-dev
Orleans__ServiceId: VehicleFleetSimulator
Persistence__ConnectionString: "DefaultEndpointsProtocol=http;AccountName=devstoreaccount1;AccountKey=Eby8vdM02xNOcqFlqUwJPLlmEtlCDXJ1OUzFT50uSRZ6IFsuFq2UVErCz4I6tq/K1SZFPTOtr/KBHBeksoGMGw==;BlobEndpoint=http://azurite:10000/devstoreaccount1;QueueEndpoint=http://azurite:10001/devstoreaccount1;TableEndpoint=http://azurite:10002/devstoreaccount1;"
ports:
- "8080:8080"
- "8081:8081"
ui:
# Blazor WebAssembly client served by nginx. Pure static hosting - the gRPC-Web
# connection is opened from the user's browser straight at the api container's
# host-mapped port (8080), so the ui service has no network dependency on the
# api beyond depends_on for predictable startup ordering.
build:
context: .
dockerfile: src/VehicleFleetSimulator.Ui/Dockerfile
container_name: vfs-ui
depends_on:
api:
condition: service_started
ports:
- "8090:80"
volumes:
azurite-data:
run.ps1
<#
.SYNOPSIS
Stand up (or tear down) the full Vehicle Fleet Simulator stack locally via docker compose.
.DESCRIPTION
Brings up Azurite + Silo + API + UI in containers, detached. The Azurite data volume
is ALWAYS wiped before starting so every run begins with clean clustering and
reminder tables. Grain state is in-memory and the sample does not configure
streams, so no durable grain or stream-queue state is preserved across runs.
.PARAMETER Down
Tear the stack down without bringing it back up. Always removes volumes.
.EXAMPLE
./run.ps1
Wipe state, build (if needed), and start the stack detached.
.EXAMPLE
./run.ps1 -Down
Stop the stack and remove all volumes.
#>
[CmdletBinding()]
param(
[switch]$Down
)
$ErrorActionPreference = 'Stop'
Set-Location (Split-Path -Parent $PSCommandPath)
function Invoke-Compose {
param([string[]]$ComposeArgs)
Write-Host "→ docker compose $($ComposeArgs -join ' ')" -ForegroundColor Cyan
& docker compose @ComposeArgs
if ($LASTEXITCODE -ne 0) { throw "docker compose exited with code $LASTEXITCODE" }
}
# Always tear down with -v to wipe the azurite-data volume before (re)starting.
Invoke-Compose @('down', '-v')
if ($Down) { return }
Invoke-Compose @('up', '--build', '-d')
src/VehicleFleetSimulator.Abstractions/City.cs
namespace VehicleFleetSimulator.Abstractions;
/// <summary>A node in the city graph.</summary>
[GenerateSerializer, Immutable]
public sealed record City(
[property: Id(0)] string Id,
[property: Id(1)] string Name);
src/VehicleFleetSimulator.Abstractions/CityGraphPersistentState.cs
using System.Collections.Generic;
namespace VehicleFleetSimulator.Abstractions;
/// <summary>Persistent state for the singleton <c>CityGraphGrain</c>: holds operator-overridden
/// 2-D positions per city id so drags survive silo restarts.</summary>
[GenerateSerializer]
public sealed class CityGraphPersistentState
{
[Id(0)] public Dictionary<string, CityPosition> Positions { get; set; } = new();
}
src/VehicleFleetSimulator.Abstractions/CityGraphSnapshot.cs
using System.Collections.Immutable;
namespace VehicleFleetSimulator.Abstractions;
/// <summary>Serializable snapshot of the configured city graph for API consumers.</summary>
/// <remarks>
/// <see cref="PositionOverrides"/> carries operator-supplied 2-D coordinates for cities the user
/// has dragged around in the UI. When empty (the default for a fresh silo) the client is expected
/// to fall back to its own deterministic layout. When present, the dictionary is the authoritative
/// source for those city ids only - un-overridden cities still take their layout-computed
/// positions, so a partial drag-set is rendered consistently.
/// </remarks>
[GenerateSerializer, Immutable]
public sealed record CityGraphSnapshot(
[property: Id(0)] ImmutableArray<City> Cities,
[property: Id(1)] ImmutableArray<RoadSegment> Edges,
[property: Id(2)] ImmutableDictionary<string, CityPosition>? PositionOverrides = null);
/// <summary>Single city's overridden 2-D position, in the same arbitrary unit space the
/// client's layout uses (x and y are unitless and unbounded; the renderer letterboxes both).</summary>
[GenerateSerializer, Immutable]
public sealed record CityPosition(
[property: Id(0)] double X,
[property: Id(1)] double Y);
src/VehicleFleetSimulator.Abstractions/DuplicateVehiclePolicy.cs
namespace VehicleFleetSimulator.Abstractions;
/// <summary>
/// Behaviour when <see cref="IFleetGrain.AddVehicle"/> or <see cref="IFleetGrain.AddVehicleBatch"/> is
/// called with a <see cref="VehicleSpec.VehicleId"/> that already exists in the fleet.
/// </summary>
[GenerateSerializer]
public enum DuplicateVehiclePolicy
{
/// <summary>Throw <see cref="InvalidOperationException"/> on duplicate (default).</summary>
Throw = 0,
/// <summary>Skip the duplicate silently and return the existing id.</summary>
Skip = 1,
}
src/VehicleFleetSimulator.Abstractions/FleetPersistentState.cs
namespace VehicleFleetSimulator.Abstractions;
/// <summary>Persistent envelope for the singleton fleet grain.</summary>
[GenerateSerializer]
public sealed class FleetPersistentState
{
[Id(0)] public HashSet<Guid> VehicleIds { get; set; } = [];
}
src/VehicleFleetSimulator.Abstractions/FleetStats.cs
namespace VehicleFleetSimulator.Abstractions;
/// <summary>Aggregate fleet statistics.</summary>
[GenerateSerializer, Immutable]
public sealed record FleetStats(
[property: Id(0)] int Total,
[property: Id(1)] int Driving,
[property: Id(2)] int Refuelling,
[property: Id(3)] int Idle,
[property: Id(4)] int RouteCompleted);
src/VehicleFleetSimulator.Abstractions/GlobalUsings.cs
global using Orleans;
src/VehicleFleetSimulator.Abstractions/ICityGraphGrain.cs
namespace VehicleFleetSimulator.Abstractions;
/// <summary>Read-only access to the silo's loaded city graph. Single instance keyed by <see cref="Key"/>.</summary>
public interface ICityGraphGrain : IGrainWithGuidKey
{
public static readonly Guid Key = Guid.Empty;
/// <summary>Return a snapshot of all cities, bidirectional road segments, and any persisted
/// per-city position overrides supplied by the UI's drag-to-move tool.</summary>
Task<CityGraphSnapshot> GetGraph();
/// <summary>Persist a 2-D position for a single city. Subsequent <see cref="GetGraph"/> calls
/// will return the override in <see cref="CityGraphSnapshot.PositionOverrides"/>. Returns
/// <c>false</c> if the city id is not part of the loaded graph.</summary>
Task<bool> SetCityPosition(string cityId, double x, double y);
/// <summary>Discard every position override and revert to the client's computed layout.</summary>
Task ClearCityPositions();
}
src/VehicleFleetSimulator.Abstractions/IFleetFanOutGrain.cs
using Orleans.Concurrency;
namespace VehicleFleetSimulator.Abstractions;
/// <summary>
/// Sharded silo-side relay that receives direct cross-grain publishes from <c>VehicleGrain</c>
/// and fans every item out to all registered <see cref="IFleetStreamObserver"/> client
/// observers. There are <see cref="StreamConstants.TelemetryAllShardCount"/> telemetry shard
/// instances; clients must register an observer with every shard to receive the full fleet
/// feed. A separate singleton activation, addressed by <see cref="EventsKey"/>, owns the
/// shared events feed -- distinct from any telemetry shard so a backlog on the (much hotter)
/// events path can't block control-plane Subscribe/Unsubscribe on a telemetry shard.
/// </summary>
public interface IFleetFanOutGrain : IGrainWithGuidKey
{
/// <summary>Grain key for telemetry shard <paramref name="shard"/>.</summary>
public static Guid ShardKey(int shard) => StreamConstants.GetTelemetryAllShardKey(shard);
/// <summary>Grain key for the singleton events-feed activation. Distinct from every
/// <see cref="ShardKey"/> so events live on their own message queue.</summary>
public static Guid EventsKey() => StreamConstants.EventsGrainKey;
// Control-plane methods are tagged [AlwaysInterleave] so they bypass the activation's
// message queue rather than waiting in line behind potentially millions of [OneWay] publish
// calls. Class-level [Reentrant] should already permit this, but [AlwaysInterleave] is a
// per-method guarantee that holds even if reentrancy is degraded under load (we observed a
// shard-0 activation hit ~2.5M queued messages with NumRunning=1 in production, which made
// every Subscribe time out at the back of the queue and prevented the gRPC hub from ever
// attaching its observer -- the UI then displayed "connected, 0 vehicles" indefinitely).
// None of these methods share mutable state with the publish path, so the interleave is
// strictly safe: Subscribe/Unsubscribe call ObserverManager (internally synchronised), and
// Ping is a no-op.
/// <summary>Register an observer. Idempotent: repeated subscriptions refresh the observer's lease.</summary>
[AlwaysInterleave] Task Subscribe(IFleetStreamObserver observer);
/// <summary>Unregister an observer if present.</summary>
[AlwaysInterleave] Task Unsubscribe(IFleetStreamObserver observer);
/// <summary>Heartbeat from the client to keep this grain activated and the observer's lease fresh.</summary>
[AlwaysInterleave] Task Ping();
/// <summary>Lightweight diagnostics: number of registered observers on this activation.
/// Tagged <see cref="AlwaysInterleaveAttribute"/> so a wedged shard can still be queried.</summary>
[AlwaysInterleave] Task<FanOutDiagnostics> GetDiagnostics();
/// <summary>Fire-and-forget publish from a <c>VehicleGrain</c> on this shard. <see cref="OneWayAttribute"/>
/// causes the caller's await to complete as soon as the message is enqueued, with no response on the wire.</summary>
[OneWay] Task PublishTelemetry(VehicleTelemetryEvent telemetry);
/// <summary>Fire-and-forget publish of a discrete event. Routed exclusively to the singleton
/// events-feed activation (see <see cref="EventsKey"/>); never published to a telemetry shard
/// so the events queue can't compete with high-volume telemetry on the same activation.</summary>
[OneWay] Task PublishEvent(VehicleEvent vehicleEvent);
}
/// <summary>Lightweight diagnostics record returned by <see cref="IFleetFanOutGrain.GetDiagnostics"/>.</summary>
[GenerateSerializer, Immutable]
public sealed record FanOutDiagnostics(
[property: Id(0)] int ObserverCount,
[property: Id(1)] long PublishedCount);
src/VehicleFleetSimulator.Abstractions/IFleetGrain.cs
namespace VehicleFleetSimulator.Abstractions;
/// <summary>
/// Singleton coordination grain for the entire fleet. Use <see cref="Key"/> as the grain key.
/// </summary>
public interface IFleetGrain : IGrainWithGuidKey
{
public static readonly Guid Key = Guid.Empty;
/// <summary>Add a single vehicle. Returns the assigned (or pre-supplied) vehicle id.</summary>
/// <param name="spec">Vehicle specification.</param>
/// <param name="onDuplicate">Behaviour when <paramref name="spec"/> carries an id already in the fleet.</param>
Task<Guid> AddVehicle(VehicleSpec spec, DuplicateVehiclePolicy onDuplicate = DuplicateVehiclePolicy.Throw);
/// <summary>Add many vehicles in chunks with bounded concurrency. Returns the assigned ids in order.</summary>
/// <param name="specs">Vehicle specifications.</param>
/// <param name="onDuplicate">Behaviour for each spec whose id is already in the fleet.</param>
Task<IReadOnlyList<Guid>> AddVehicleBatch(IReadOnlyList<VehicleSpec> specs, DuplicateVehiclePolicy onDuplicate = DuplicateVehiclePolicy.Throw);
/// <summary>Remove a vehicle (stops it and forgets the id). Returns true if the vehicle was known.</summary>
Task<bool> RemoveVehicle(Guid vehicleId);
/// <summary>Remove every vehicle in the fleet (stops each and clears the roster). Returns the number removed.</summary>
Task<int> RemoveAllVehicles();
/// <summary>List all known vehicle ids.</summary>
Task<IReadOnlyList<Guid>> ListVehicles();
/// <summary>Aggregate fleet stats by walking each vehicle grain.</summary>
Task<FleetStats> GetFleetStats();
/// <summary>Issue <see cref="IVehicleGrain.Start"/> to every persisted vehicle. Returns the
/// number of vehicles successfully started. Mirrors <see cref="RemoveAllVehicles"/>'s
/// bounded-concurrency fan-out so timing on large rosters stays inside the Orleans default
/// response timeout.</summary>
Task<int> StartAllVehicles();
/// <summary>Issue <see cref="IVehicleGrain.Stop"/> to every persisted vehicle. Returns the
/// number of vehicles successfully stopped. Vehicles remain in the roster (use
/// <see cref="RemoveAllVehicles"/> to also forget them).</summary>
Task<int> StopAllVehicles();
}
src/VehicleFleetSimulator.Abstractions/IFleetStreamObserver.cs
namespace VehicleFleetSimulator.Abstractions;
/// <summary>
/// Client-side observer that receives a stream of fleet telemetry and discrete events relayed
/// from the silo's <c>FleetFanOutGrain</c>. Implementations must be tolerant of out-of-order or
/// duplicate items.
/// </summary>
public interface IFleetStreamObserver : IGrainObserver
{
Task OnTelemetry(VehicleTelemetryEvent telemetry);
Task OnEvent(VehicleEvent vehicleEvent);
}
src/VehicleFleetSimulator.Abstractions/IPingGrain.cs
using Orleans;
namespace VehicleFleetSimulator.Abstractions;
/// <summary>Round-trip verification grain for Milestone 0.</summary>
public interface IPingGrain : IGrainWithStringKey
{
Task<string> Ping(string message);
}
src/VehicleFleetSimulator.Abstractions/ISimulationConfigGrain.cs
namespace VehicleFleetSimulator.Abstractions;
/// <summary>Singleton grain holding the global <see cref="SimulationConfig"/> for the fleet.</summary>
public interface ISimulationConfigGrain : IGrainWithGuidKey
{
public static readonly Guid Key = Guid.Empty;
/// <summary>Return the current global simulation configuration.</summary>
Task<SimulationConfig> GetConfig();
/// <summary>Apply a partial update; returns the merged result.</summary>
Task<SimulationConfig> UpdateConfig(SimulationConfigPatch patch);
}
src/VehicleFleetSimulator.Abstractions/ITelemetrySink.cs
namespace VehicleFleetSimulator.Abstractions;
/// <summary>
/// Sink seam that <see cref="IVehicleGrain"/> writes per-tick telemetry and discrete events to.
/// Introduced so the same simulator load can drive different downstream pipelines without
/// modifying grain code:
///
/// <list type="bullet">
/// <item><description><c>FanOutTelemetrySink</c> - current behavior, dispatches to the sharded
/// <see cref="IFleetFanOutGrain"/> relay (used by <c>FleetStreamHub</c> + load harness).</description></item>
/// <item><description><c>NullTelemetrySink</c> - discards every publish, for isolating producer-side
/// cost (benchmark scenario simulator-baseline baseline / observer-off control).</description></item>
/// <item><description>Future <c>LatticeSink</c> - writes telemetry to an <c>Orleans.Lattice</c> tree
/// (benchmark scenarios current-state-no-replication and onward).</description></item>
/// </list>
///
/// <para>Implementations must be safe to call from the <see cref="IVehicleGrain"/> turn at the
/// configured tick cadence across the entire fleet. Implementations that perform external I/O
/// should buffer off-turn so grain-tick latency is not coupled to the downstream's latency.</para>
/// </summary>
public interface ITelemetrySink
{
/// <summary>
/// Publish a per-tick telemetry sample. Called once per <see cref="IVehicleGrain"/> tick on the
/// hot path; implementations should be allocation-free in steady state and must not throw.
/// </summary>
ValueTask PublishTelemetryAsync(VehicleTelemetryEvent telemetry, CancellationToken cancellationToken = default);
/// <summary>
/// Publish a discrete vehicle event (route start / stop, refuel, status transition).
/// Called only on ticks that produce events, so cost matters less than the telemetry path.
/// </summary>
ValueTask PublishEventAsync(VehicleEvent vehicleEvent, CancellationToken cancellationToken = default);
}
src/VehicleFleetSimulator.Abstractions/IVehicleGrain.cs
using System.Collections.Immutable;
namespace VehicleFleetSimulator.Abstractions;
/// <summary>
/// Single vehicle simulator grain. One grain instance per vehicle, keyed by <c>VehicleId</c> (Guid).
/// </summary>
public interface IVehicleGrain : IGrainWithGuidKey
{
/// <summary>Initialize a freshly activated grain from a spec. Idempotent if called multiple times with the same spec.</summary>
Task Initialize(VehicleSpec spec);
/// <summary>Begin ticking on the configured interval and publishing telemetry.</summary>
Task Start();
/// <summary>Stop ticking. State is preserved.</summary>
Task Stop();
/// <summary>Stop ticking, purge all persisted state for this vehicle, and deactivate the grain.</summary>
Task Clear();
/// <summary>Replace per-vehicle configuration (fuel curve, speed bounds, refuel delay, etc.).</summary>
Task UpdateConfig(VehicleConfig config);
/// <summary>Replace the current route. The vehicle is re-positioned at the first city of the new route.</summary>
Task SetRoute(ImmutableArray<string> route);
/// <summary>Return the current snapshot, or null if not yet initialized.</summary>
/// <remarks>Returns <see cref="ValueTask{T}"/> so the synchronous fast path (state already
/// in memory) doesn't allocate a <see cref="Task{T}"/> via <c>Task.FromResult</c>.</remarks>
ValueTask<VehicleSnapshot?> GetSnapshot();
/// <summary>Inject a discrete malfunction. Returns <c>true</c> if the fault was applied
/// (always true today, but reserved for future "fault not applicable in this state" cases).</summary>
Task<bool> InjectFault(VehicleFault fault);
}
Remaining files
This sample is too large to inline in full. The remaining 83 file(s) are in the repository:
- src/VehicleFleetSimulator.Abstractions/RoadSegment.cs
- src/VehicleFleetSimulator.Abstractions/ScenarioPreset.cs
- src/VehicleFleetSimulator.Abstractions/SimulationConfig.cs
- src/VehicleFleetSimulator.Abstractions/SimulationConfigPersistentState.cs
- src/VehicleFleetSimulator.Abstractions/StreamConstants.cs
- src/VehicleFleetSimulator.Abstractions/VehicleConfig.cs
- src/VehicleFleetSimulator.Abstractions/VehicleEvent.cs
- src/VehicleFleetSimulator.Abstractions/VehicleFault.cs
- src/VehicleFleetSimulator.Abstractions/VehicleFleetSimulator.Abstractions.csproj
- src/VehicleFleetSimulator.Abstractions/VehiclePersistentState.cs
- src/VehicleFleetSimulator.Abstractions/VehicleSnapshot.cs
- src/VehicleFleetSimulator.Abstractions/VehicleSpec.cs
- src/VehicleFleetSimulator.Abstractions/VehicleState.cs
- src/VehicleFleetSimulator.Abstractions/VehicleStatus.cs
- src/VehicleFleetSimulator.Abstractions/VehicleTelemetryEvent.cs
- src/VehicleFleetSimulator.Api/appsettings.Development.json
- src/VehicleFleetSimulator.Api/appsettings.json
- src/VehicleFleetSimulator.Api/Properties/launchSettings.json
- src/VehicleFleetSimulator.Api/Protos/fleet_stream.proto
- src/VehicleFleetSimulator.Api/Services/ApiKeyEndpointFilter.cs
- src/VehicleFleetSimulator.Api/Services/ApiKeyInterceptor.cs
- src/VehicleFleetSimulator.Api/Services/FleetStreamService.cs
- src/VehicleFleetSimulator.Api/Services/RecordingService.cs
- src/VehicleFleetSimulator.Api/Services/SimulationEventBroadcaster.cs
- src/VehicleFleetSimulator.Api/Streams/FleetStreamHub.cs
- src/VehicleFleetSimulator.Api/Streams/TelemetryFanOutService.cs
- src/VehicleFleetSimulator.Api/VehicleFleetSimulator.Api.csproj
- src/VehicleFleetSimulator.Grains.Lattice/KeyShape.cs
- src/VehicleFleetSimulator.Grains.Lattice/LatticeSink.cs
- src/VehicleFleetSimulator.Grains.Lattice/LatticeSinkMetrics.cs
- src/VehicleFleetSimulator.Grains.Lattice/LatticeSinkOptions.cs
- src/VehicleFleetSimulator.Grains.Lattice/LatticeSinkServiceCollectionExtensions.cs
- src/VehicleFleetSimulator.Grains/Cities/CityGraph.cs
- src/VehicleFleetSimulator.Grains/Cities/CityGraphOptions.cs
- src/VehicleFleetSimulator.Grains/Cities/ICityGraphProvider.cs
- src/VehicleFleetSimulator.Grains/Cities/RouteGenerator.cs
- src/VehicleFleetSimulator.Grains/CityGraphGrain.cs
- src/VehicleFleetSimulator.Grains/FleetFanOutGrain.cs
- src/VehicleFleetSimulator.Grains/FleetGrain.cs
- src/VehicleFleetSimulator.Grains/GlobalUsings.cs
- src/VehicleFleetSimulator.Grains/PingGrain.cs
- src/VehicleFleetSimulator.Grains/SimulationConfigGrain.cs
- src/VehicleFleetSimulator.Grains/SimulationRuntimeState.cs
- src/VehicleFleetSimulator.Grains/Telemetry/FanOutTelemetrySink.cs
- src/VehicleFleetSimulator.Grains/Telemetry/NullTelemetrySink.cs
- src/VehicleFleetSimulator.Grains/VehicleFleetSimulator.Grains.csproj
- src/VehicleFleetSimulator.Grains/VehicleGrain.cs
- src/VehicleFleetSimulator.Grains/Vehicles/FuelModel.cs
- src/VehicleFleetSimulator.Grains/Vehicles/SpeedModel.cs
- src/VehicleFleetSimulator.Grains/Vehicles/VehicleSimulator.cs
- src/VehicleFleetSimulator.Silo/appsettings.json
- src/VehicleFleetSimulator.Silo/VehicleFleetSimulator.Silo.csproj
- src/VehicleFleetSimulator.Ui/_Imports.razor
- src/VehicleFleetSimulator.Ui/App.razor
- src/VehicleFleetSimulator.Ui/Components/ControlFlyout.razor
- src/VehicleFleetSimulator.Ui/Components/SimSpeedSlider.razor
- src/VehicleFleetSimulator.Ui/Layout/MainLayout.razor
- src/VehicleFleetSimulator.Ui/Models/CityGraphDto.cs
- src/VehicleFleetSimulator.Ui/Pages/Home.razor
- src/VehicleFleetSimulator.Ui/Properties/launchSettings.json
- src/VehicleFleetSimulator.Ui/Protos/fleet_stream.proto
- src/VehicleFleetSimulator.Ui/Rendering/CityLayout.cs
- src/VehicleFleetSimulator.Ui/Services/FleetAdminClient.cs
- src/VehicleFleetSimulator.Ui/Services/FleetState.cs
- src/VehicleFleetSimulator.Ui/Services/FleetStreamClient.cs
- src/VehicleFleetSimulator.Ui/Services/SimulationConfigClient.cs
- src/VehicleFleetSimulator.Ui/VehicleFleetSimulator.Ui.csproj
- src/VehicleFleetSimulator.Ui/wwwroot/appsettings.json
- tests/VehicleFleetSimulator.Tests/CityGraphTests.cs
- tests/VehicleFleetSimulator.Tests/ClusterFixture.cs
- tests/VehicleFleetSimulator.Tests/FanOutTelemetrySinkRoutingTests.cs
- tests/VehicleFleetSimulator.Tests/FleetBatchTelemetryTests.cs
- tests/VehicleFleetSimulator.Tests/FleetGrainIdempotencyTests.cs
- tests/VehicleFleetSimulator.Tests/FuelModelTests.cs
- tests/VehicleFleetSimulator.Tests/NullTelemetrySinkTests.cs
- tests/VehicleFleetSimulator.Tests/RouteGeneratorTests.cs
- tests/VehicleFleetSimulator.Tests/SpeedModelTests.cs
- tests/VehicleFleetSimulator.Tests/StreamSubscriberOrderTests.cs
- tests/VehicleFleetSimulator.Tests/TelemetrySinkSwappabilityTests.cs
- tests/VehicleFleetSimulator.Tests/TestGraph.cs
- tests/VehicleFleetSimulator.Tests/VehicleFleetSimulator.Tests.csproj
- tests/VehicleFleetSimulator.Tests/VehicleLifecycleTests.cs
- tests/VehicleFleetSimulator.Tests/VehicleSimulatorTests.cs