Energy Hub

NO WARRANTY. Trax auth is plumbing, not a security product. You are solely responsible for securing systems that use it. See API Security.

samples/DistributedWorkers in Trax.Samples splits scheduling from execution. One process, the hub, owns the GraphQL API, the scheduler and the dashboard. Separate worker processes run every job the hub queues. The two share PostgreSQL (the background_job table) and RabbitMQ (lifecycle events), and nothing else, so you can run as many workers as the load needs.

What it proves

FeatureWhere
A scheduler that queues but never executes: OverrideSubmitter registers PostgresJobSubmitter alone, so no local worker startsHub/Program.cs, HubExecutesNoTrainsTests
A standalone worker: AddTraxWorker claims jobs from background_job with FOR UPDATE SKIP LOCKEDWorker/Program.cs
A completion the worker publishes reaches a GraphQL subscription on the hub, over RabbitMQCrossProcessEventTests
Gated mutations and a gated operations namespace on a hub that keeps one anonymous queryGraphQLTests, OperationsCredentialTests
A worker that runs [TraxAuthorize] trains without an API of its own (AllowMissingAuthorizationService)Worker/Program.cs
Interval, cron, dependent and batch manifests declared on the hubManifestConfigurationTests

Layout

samples/DistributedWorkers/
├── Trax.Samples.EnergyHub/          trains, manifest names, roles
├── Trax.Samples.EnergyHub.Hub/      GraphQL API + scheduler + dashboard (port 5202)
└── Trax.Samples.EnergyHub.Worker/   AddTraxWorker (port 5203, serves nothing)

Run

From the Trax.Samples root:

docker compose up -d       # Postgres on 5432 (database trax_energyhub), RabbitMQ on 5672 (user trax, password trax123)
 
# Terminal 1: the hub, in Development, on http://localhost:5202
dotnet run --project samples/DistributedWorkers/Trax.Samples.EnergyHub.Hub
 
# Terminal 2: a worker; start more to scale out
dotnet run --project samples/DistributedWorkers/Trax.Samples.EnergyHub.Worker

dotnet run starts both in Development through Properties/launchSettings.json. That is the only environment that registers the demo operator key and serves the dashboard at http://localhost:5202/trax. Started any other way, the hub serves no dashboard and accepts no key, so its mutations and operations namespace refuse every caller until you register real credentials.

Try it

The anonymous query runs on the hub itself:

curl -s http://localhost:5202/trax/graphql -H "Content-Type: application/json" \
  -d '{"query":"{ discover { solar { monitorSolarProduction(input: {arrayId: \"SPA-001\", region: \"somerset\"}) { arrayId totalKwh efficiency } } } }"}'
{"data":{"discover":{"solar":{"monitorSolarProduction":{"arrayId":"SPA-001","totalKwh":142.7,"efficiency":0.89}}}}}

Queue a grid trade with the operator key. The mutation returns at once; a few seconds later the worker's console logs the trade and the hub's does not:

curl -s http://localhost:5202/trax/graphql -H "Content-Type: application/json" \
  -H "X-Api-Key: energyhub-operator-key-do-not-use-in-production" \
  -d '{"query":"mutation { dispatch { tradeGridEnergy(input: {ratePerKwh: 0.14, maxSellPercent: 80}) { externalId workQueueId } } }"}'

Read the scheduler's manifests, then try the same query without the key:

curl -s http://localhost:5202/trax/graphql -H "Content-Type: application/json" \
  -H "X-Api-Key: energyhub-operator-key-do-not-use-in-production" \
  -d '{"query":"{ operations { manifests(take: 5) { items { externalId scheduleType } } } }"}'
 
curl -s http://localhost:5202/trax/graphql -H "Content-Type: application/json" \
  -d '{"query":"{ operations { manifests(take: 5) { items { externalId } } } }"}'

The second answers {"errors":[{"message":"Not authorized.","path":["operations"],"extensions":{"code":"TRAX_AUTHORIZATION"}}],"data":{"operations":null}}.

These commands are also in the hub's Program.cs header, and DocumentedExamplesTests reads them from there and runs them, so the header cannot drift from the schema.

How it works

The hub schedules and queues, and runs nothing it queues

using Trax.Scheduler.Extensions;
using Trax.Scheduler.Services.JobSubmitter;
 
builder.Services.AddTrax(trax =>
    trax.AddEffects(effects =>
            effects
                .UsePostgres(connectionString)
                .AddJson()
                .UseBroadcaster(b =>
                    b.UseRabbitMq(rabbitMqConnectionString, o => o.ExchangeName = LifecycleEvents.ExchangeName)
                )
        )
        .AddMediator(typeof(ManifestNames).Assembly)
        .AddScheduler(scheduler =>
            scheduler
                .OverrideSubmitter(services =>
                    services.AddScoped<IJobSubmitter, PostgresJobSubmitter>()
                )
                .Schedule<IMonitorSolarProductionTrain>(
                    ManifestNames.MonitorSolarProduction,
                    new MonitorSolarProductionInput { ArrayId = "SPA-001", Region = "somerset" },
                    Every.Minutes(5).WithVariance(TimeSpan.FromMinutes(1))
                )
                // ... more manifests
        )
);

A scheduler on Postgres registers PostgresJobSubmitter and starts LocalWorkerService by default, so a hub without OverrideSubmitter runs its own jobs and races the workers for them. Naming the submitter registers it alone: dispatched jobs are written to background_job and wait there for a worker. See Remote Execution: Standalone Workers.

What still runs on the hub is anything answered synchronously: a [TraxQuery], and a mutation in RUN mode. That is why every EnergyHub mutation is declared GraphQLOperation.Queue, and why its one query, a live sensor read, is the only train the hub executes.

The worker

using Trax.Effect.Broadcaster.RabbitMQ.Extensions;
using Trax.Effect.Data.Postgres.Extensions;
using Trax.Effect.Extensions;
using Trax.Mediator.Extensions;
using Trax.Samples.EnergyHub;
using Trax.Scheduler.Extensions;
 
var builder = WebApplication.CreateBuilder(args);
 
builder.Services.AddTrax(trax =>
    trax.AddEffects(effects =>
            effects
                .UsePostgres(connectionString)
                .AddJson()
                .UseBroadcaster(b =>
                    b.UseRabbitMq(rabbitMqConnectionString, o => o.ExchangeName = LifecycleEvents.ExchangeName)
                )
        )
        .AddMediator(mediator =>
            mediator
                .ScanAssemblies(typeof(ManifestNames).Assembly)
                .AllowMissingAuthorizationService()
        )
);
 
builder.Services.AddTraxWorker(opts =>
{
    opts.WorkerCount = 4;
    opts.PollingInterval = TimeSpan.FromSeconds(1);
});
 
var app = builder.Build();
app.Run();

The worker references the same train assembly, so it can resolve every train the hub queues. The mutations carry [TraxAuthorize(Roles = "Operator")], and the mediator refuses to start a host that has such trains and no ITrainAuthorizationService. The worker has no API and never sees a caller: the hub checked the role when the job was queued, and work the scheduler hands over is trusted. AllowMissingAuthorizationService() says exactly that. See Authorization: Opting Out for Scheduler-Only Hosts.

Events come home over RabbitMQ

Both processes call UseBroadcaster(b => b.UseRabbitMq(...)) with the same broker and the same exchange, energyhub.lifecycle (LifecycleEvents.ExchangeName). Every process bound to an exchange receives every event on it, so a sample of its own needs an exchange of its own, or another Trax application on the broker would receive its events. The worker publishes each run's lifecycle events to that fanout exchange; the hub's TrainEventReceiverService consumes them, and because the hub calls AddTraxGraphQL(), Trax forwards them to its GraphQL subscriptions. A subscriber on the hub therefore sees onTrainCompleted for a trade that ran on a worker:

subscription { onTrainCompleted { externalId trainName output } }

The connection string must name a user the broker accepts. A refused connection does not fail anything: the receiver logs a warning and retries forever, and a failed publish never fails a run, so a wrong password silently turns cross-process subscriptions off. The sample, its tests, docker-compose.yml and CI all use amqp://trax:trax123@localhost:5672. See UseBroadcaster.

Who may operate the hub

using Trax.Api.Auth.ApiKey;
 
if (builder.Environment.IsDevelopment())
    builder.Services.AddTraxApiKeyAuth(keys =>
        keys.Add(DemoKeys.OperatorKey, id: "operator", EnergyHubRoles.Operator)
    );
builder.Services.AddAuthentication();
builder.Services.AddAuthorization();
 
if (builder.Environment.IsDevelopment())
    builder.AddTraxDashboard(dashboard => dashboard.AllowAnonymousDashboard());
 
builder.Services.AddTraxGraphQL(graphql =>
    graphql
        .MaxExecutionDepth(6)
        .ExposeOperationQueries()
        .ExposeOperationMutations()
        .GateOperations(roles: EnergyHubRoles.Operator)
);
 
var app = builder.Build();
 
app.UseAuthentication();
app.UseAuthorization();
if (app.Environment.IsDevelopment())
    app.UseTraxDashboard();
app.UseTraxGraphQL();

GateOperations gates the operations namespace alone, so the anonymous solar query keeps working, while each mutation is gated by its own [TraxAuthorize]. The dashboard has no login in front of it, so it is registered and served only in Development; UseTraxDashboard() refuses to start until the dashboard has a posture, which AllowAnonymousDashboard() gives it there. See API Security and Dashboard.

Tests

TRAX_TEST_PG_PORT=5432 dotnet test tests/Trax.Samples.EnergyHub.E2E

The suite starts the hub and a worker in one test process with two WebApplicationFactory instances, against the energyhub_e2e_tests database. TRAX_TEST_PG_PORT moves Postgres and TRAX_TEST_RABBITMQ replaces the broker URI.

Test classProves
HubExecutesNoTrainsTestsThe hub has no LocalWorkerService and its submitter is PostgresJobSubmitter; the worker runs the local worker
CrossProcessEventTestsA trade the worker runs reaches an onTrainCompleted subscriber on the hub
GraphQLTestsThe query is anonymous, a queued report completes on the worker, an anonymous trade is refused
OperationsCredentialTestsThe control plane refuses an anonymous read or trigger and admits the operator
DocumentedExamplesTestsEvery curl command in the hub's header works as written
ManifestConfigurationTests, DependencyChainTestsThe manifests the hub declares, and the dependent battery train following the solar read

SDK Reference

AddScheduler / OverrideSubmitter | AddTraxWorker | UseBroadcaster | AddTraxGraphQL | AddTraxApiKeyAuth | UseTraxDashboard | Subscriptions