Phase 16a — `ProcessProfile` gating (`WebOnly` / `WorkerOnly` / `DispatcherOnly`)
Phase 16a — ProcessProfile gating (WebOnly / WorkerOnly / DispatcherOnly)
What changes. ServerConfig.ProcessProfile now drives IHostedService registration in SDK.Server.compose through a centralised gating matrix. Each role activates a documented subset of the SDK's background subsystems so a deployment can horizontally scale per role against a shared persistence tier — same binary, same image, different TOOLUP_PROCESS_PROFILE env var.
The matrix lives in Server/Compose/ProcessProfileGate.fs and combines two orthogonal ServerConfig switches:
ServerlessHost(Phase 16) —ServerlessHostshort-circuits every background subsystem (host adapter platforms don't keep the process alive long enough for any of them to run).ProcessProfile(Phase 16a) —AllInOne(default) runs everything in one process;WebOnlyruns none;WorkerOnlyruns everything;DispatcherOnlyruns only the outbound dispatchers.
Gating matrix
| Subsystem | AllInOne | WebOnly | WorkerOnly | DispatcherOnly | ServerlessHost (any profile) |
|---|---|---|---|---|---|
| Job scheduler | ✓ | ✗ | ✓ | ✗ | ✗ |
| Webhook dispatcher | ✓ | ✗ | ✓ | ✓ | ✗ |
| Transactional dispatcher | ✓ | ✗ | ✓ | ✓ | ✗ |
| Audit replicator | ✓ | ✗ | ✓ | ✗ | ✗ |
| Usage batch flusher | ✓ | ✗ | ✓ | ✗ | ✗ |
| Health-state tracker | ✓ | ✗ | ✓ | ✗ | ✗ |
| OAuth state-store cleanup | ✓ | ✗ | ✓ | ✗ | ✗ |
| OAuth refresher startup-Recover | ✓ | ✗ | ✓ | ✗ | ✗ |
| HTTP middleware pipeline | ✓ | ✓ | ✗ | ✓ | ✓ |
Every gate-site in compose (and the per-concern Compose helpers) calls ProcessProfileGate.shouldRegisterBackgroundService with one of the BackgroundSubsystem DU cases. Adding a new subsystem only edits the matrix in one place.
Diff to apply
Existing deployments running the default ProcessProfile = AllInOne: no change. AllInOne runs every background subsystem (identical to today's behaviour).
Deployments wanting horizontal-scale-per-role: flip the role per silo via ServerConfig.ProcessProfile or the TOOLUP_PROCESS_PROFILE env var (when ServerConfigOverrides.fromEnv is wired):
// Web tier silo — serves /api/* only; sibling worker drains jobs:
ServerApp.empty
|> ServerApp.withConfig {
ServerConfig.defaults with
Mode = Team
ProcessProfile = WebOnly
// Persistence + cross-silo channel — point both silos at the same:
EventStore = PersistentBlobBacked Retention.defaults
JobScheduler = InProcessJobScheduler // substrate still registers
Notifications = NotificationsAuto // wire Redis channel separately
}
// Worker tier silo — runs every background subsystem; no HTTP:
ServerApp.empty
|> ServerApp.withConfig {
ServerConfig.defaults with
Mode = Team
ProcessProfile = WorkerOnly
EventStore = PersistentBlobBacked Retention.defaults
JobScheduler = InProcessJobScheduler
Notifications = NotificationsAuto
}
Both silos run from the same binary; the only difference is ProcessProfile. The Web tier registers job-scheduling routes (so JobApiHandler resolves IJobScheduler and persists JobDefinitions), but the scheduler BackgroundService doesn't tick locally — the Worker silo's BackgroundService registration drains the persisted queue.
Cross-silo coordination caveats
- Single-leader concerns (cron-due-job tick, webhook retry timer) need cross-process coordination so two
WorkerOnlyinstances don't double-fire. Phase 9iIDistributedLockships the coordination layer; until it lands, aProcessProfile = WorkerOnlydeployment should keepReplicaCount = 1. This is tracked as a deferred follow-up. - Notification channel must be a distributed implementation (Redis pub/sub via
src/NotificationChannels/Redis) — the defaultInMemoryNotificationChannelwon't bridge events between silos. - Event store must be
PersistentBlobBackedfor the Web tier to write events the Worker tier reads (the in-memory store is per-process).
WorkerOnly construction shape
The WorkerOnly profile today builds the same WebApplication.CreateBuilder() chain as every other profile, but configurePipeline is bypassed when ProcessProfileGate.shouldRegisterHttpPipeline config = false. Kestrel binds to its configured port but no handlers respond; sibling silos should not route HTTP at a WorkerOnly deployment.
A future refactor will swap to Host.CreateApplicationBuilder() for the WorkerOnly shape so the silo binds no port at all (the IServerHost interface's createWorkerHost helper supports it; the gating just doesn't construct it yet). Tracked on the phase body's deferred follow-ups.
Verification
dotnet build ToolUp.Forge.sln— clean.dotnet run --project src/ToolUp.Platform.Tests/ToolUp.Platform.Tests.fsproj— 0 failures, 0 errored.- Boot a stock deployment (default
AllInOne) — startup log shows every IHostedService registration as today. - Boot a
ProcessProfile = WebOnlydeployment — the/dev/inspectComposition-seam panel (when shipped) confirms 0 background services registered;/api/*still serves. - Boot a
ProcessProfile = WorkerOnlydeployment — confirm[Phase 16a] ProcessProfile = WorkerOnlylog line appears at compose, and no/api/*request returns 200 (HTTP not mounted).
Rollback
Reset ServerConfig.ProcessProfile = AllInOne; the gating short-circuits restore today's behaviour byte-for-byte.
Out of scope (Phase 16a follow-ups)
Shipped — Phase 16a tail. The dev-inspect report now carries a/dev/inspectComposition-seam panel reports the activeProcessProfileand the per-subsystem registration outcome.ProcessProfilesection with per-subsystem registration outcomes (seeDevDiagnosticsHandler.fs— theProcessProfileSummaryDTO + the dedicated HTML panel).TECHNICAL_GUIDE deployment-shapes section.Shipped — Phase 16a tail. Lives attechnical-guide/13-deployment-shapes.mdwith the three pure-Kestrel shapes (AllInOne/WebOnly+WorkerOnly/WebOnly+WorkerOnly+DispatcherOnly), the substrate contract, and the cross-silo coordination contract.IDistributedLock(Phase 9i) cross-silo single-leader coordination. (Deferred — Phase 9i unshipped;ReplicaCount = 1is the only safe deployment shape forWorkerOnlyuntil it lands.)WorkerOnly→Host.CreateApplicationBuilder()(no Kestrel binding) refactor. TheIServerHost.createWorkerHosthelper supports it; the compose-side construction-branch refactor is the follow-up commit.
Consumers
The migration is N-A for any consumer running the default ProcessProfile = AllInOne — no source-level change is needed; the gating matrix is opt-in by setting ProcessProfile to a non-default case.
A consumer adopting horizontal-scale-per-role also adopts a distributed INotificationChannel (Redis), a PersistentBlobBacked IEventStore, and pins ReplicaCount = 1 on the WorkerOnly silo until IDistributedLock ships.