Substantive.Infrastructure.ContainerJobRunner 9.17.34-build20260914r1

Substantive.Infrastructure.ContainerJobRunner

A .NET library that lets a host application trigger a standalone Worker Service across different deployment targets without changing the calling code. The deployment mode is selected at call time via an enum, and the library routes to the correct backend.


Why the name ContainerJobRunner

The library runs a container job — one unit of work that runs to completion and then exits — with an Azure Container Apps (ACA) Job as the primary production target. The name is built from those three parts: Container + Job + Runner.

  • Job captures the run-once-and-exit semantics across all modes — local process, local Docker, remote image, and ACA Job alike.
  • Container reflects the primary substrate: in production every job runs as a container (an ACA Job). The DotNetWorkerServiceProject mode, which runs the job as a bare dotnet OS process, is a development/debug convenience — a fast inner-loop stand-in for the same container job.
  • Runner is the action the library performs: it runs the job and returns immediately (fire-and-forget).

The name lines up with the type surface callers use: IContainerJobRunnerFacade, IContainerJobRunner, ContainerJobRunnerOptions, and AddContainerJobRunner().


Purpose and mental model

A Worker Service is a self-contained .NET process or container that starts, does one unit of work, and exits. The runner is the bridge between a host app (a WebAPI, console app, or another ACA Container App) and the worker's deployment boundary.

The host app calls IContainerJobRunnerFacade.RunAsync(jobName, mode, payload) and the library handles everything else: creating or starting the process / container / ACA Job, injecting the payload, and returning immediately (fire-and-forget). The host never manages the worker's lifecycle directly.


Deploy modes

WorkerDeploymentMode enum
├─ DotNetWorkerServiceProject (10) — launches worker .dll as a local OS process via dotnet
├─ LocalContainer          (11) — builds Docker image from Dockerfile, runs with --rm
├─ LocalContainerImage     (12) — runs a locally cached image; builds+caches if missing
├─ RemoteContainerImage    (13) — docker pull from registry, then docker run --rm
└─ AcaJob                  (21) — Azure Container Apps Job (production mode)

The first four are for development and testing. AcaJob is the production mode.


How AcaJob mode works (production scenario)

This is the primary production mode. The host application (e.g. Stub.Api) runs inside Azure Container Apps as a regular Container App. When it needs to run background work, it calls the runner with WorkerDeploymentMode.AcaJob.

Execution flow:

Stub.Api (ACA Container App)
  └─ IContainerJobRunnerFacade.RunAsync("MyWorker", AcaJob, payload)
       └─ AcaJobRunner
            1. GET /subscriptions/.../jobs/{jobName}
               ├─ 200 OK  → job already exists, skip provisioning
               └─ 404     → CreateJobAsync()
                             ├─ derives location from AcaEnvironmentId
                             ├─ creates ManualTrigger job (10-minute replica timeout)
                             ├─ registers registry credentials (or skips for Managed Identity pull)
                             └─ sets container image + 0.5 vCPU / 1 Gi
            2. jobResource.StartAsync(WaitUntil.Started, executionTemplate)
               └─ injects WORKER_PAYLOAD env var into the execution
            3. returns immediately (fire-and-forget)
               └─ ACA runs the container to completion, then auto-destroys it

What persists vs what is destroyed:

Artifact Lifecycle
ACA Job ARM resource (Microsoft.App/jobs/{name}) Created once on first call; reused on every subsequent call
Job execution (container instance) Started per call; ACA destroys the container automatically when it exits
Execution history record Retained in ACA (visible in Azure Portal under Execution History)

The "destroy the container" expectation is correct: ACA auto-destroys the container instance after the worker exits. The job definition template stays in Azure and is reused.


On-the-fly job provisioning

If the ACA Job ARM resource identified by JobResourceId does not yet exist, the runner provisions it automatically using:

  • AcaEnvironmentId — which Container Apps Environment to deploy into (used to derive the Azure region)
  • ContainerImage — the registry image for the worker
  • RegistryServer / RegistryUsername / RegistryPassword — only needed when not using Managed Identity for ACR pull

Once created, the job ARM resource is never deleted by the runner. Subsequent calls skip provisioning.

Required configuration for on-the-fly provisioning:

"ContainerJobRunnerOptions": {
  "Workers": [
    {
      "JobName": "MyWorker",
      "JobResourceId": "/subscriptions/{sub}/resourceGroups/{rg}/providers/Microsoft.App/jobs/{name}",
      "AcaEnvironmentId": "/subscriptions/{sub}/resourceGroups/{rg}/providers/Microsoft.App/managedEnvironments/{env}",
      "ContainerImage": "myregistry.azurecr.io/my-worker:1.0.0"
    }
  ]
}

If the job already exists in Azure, only JobResourceId is required.


Payload delivery

The payload is serialised as JSON and injected into every invocation target via the WORKER_PAYLOAD environment variable — identical across all five modes.

var payload = new JobPayload(
    JobId: Guid.CreateVersion7().ToString(),
    Parameters: new Dictionary<string, string>
    {
        ["customerId"] = request.CustomerId,
        ["reportDate"] = request.ReportDate.ToString("O"),
    });

await factory.RunAsync("MyWorker", WorkerDeploymentMode.AcaJob, payload, ct);

The Worker Service reads the payload:

var json = Environment.GetEnvironmentVariable("WORKER_PAYLOAD")
    ?? throw new InvalidOperationException("WORKER_PAYLOAD env var not set.");
var payload = JsonSerializer.Deserialize<JobPayload>(json)!;

Authentication

The library registers ArmClient with DefaultAzureCredential:

  • Local development — uses the signed-in Azure CLI identity (az login)
  • Production (ACA) — uses the host app's Managed Identity

For ACA Job provisioning and execution, the host app's identity needs at minimum:

  • Azure Container Apps Contributor role on the resource group (or the job resource itself)
  • AcrPull role on the container registry when using Managed Identity for image pull

If registry credentials are set in configuration (RegistryServer, RegistryUsername, RegistryPassword), Managed Identity pull is skipped and the provided credentials are used instead.


DI registration

// Program.cs (in the host app — e.g. Stub.Api)
ContainerJobRunnerOptions options = new();
builder.Configuration.GetSection(nameof(ContainerJobRunnerOptions)).Bind(options);
builder.Services.AddContainerJobRunner(options.ToAction());

AddContainerJobRunner registers all five runner implementations and IContainerJobRunnerFacade. Validation runs at host startup — a misconfigured worker fails fast with a clear error message.

Inject and use:

public class ReportController(IContainerJobRunnerFacade containerJobRunner)
{
    [HttpPost("reports")]
    public async Task<IActionResult> GenerateAsync(GenerateReportRequest request, CancellationToken ct)
    {
        var payload = new JobPayload(
            JobId: Guid.CreateVersion7().ToString(),
            Parameters: new Dictionary<string, string> { ["reportId"] = request.ReportId });

        await containerJobRunner.RunAsync("ReportWorker", WorkerDeploymentMode.AcaJob, payload, ct);

        return Accepted(new { payload.JobId });
    }
}

Configuration shape reference

WorkerDefinition uses flat properties. The composition sub-objects (AcaJob, DotNetProcess, etc.) exist for validation but the runners read from the flat properties directly.

Property Deploy mode(s) Required
JobName all yes
JobResourceId AcaJob yes
AcaEnvironmentId AcaJob only for on-the-fly provisioning
ContainerImage AcaJob only for on-the-fly provisioning
RegistryServer / RegistryUsername / RegistryPassword AcaJob, RemoteContainerImage only when not using Managed Identity
AssemblyPath DotNetWorkerServiceProject yes
DockerfilePath / BuildContext LocalContainer yes
ImageName LocalContainerImage, RemoteContainerImage yes
EnableDebugPort / DebugPort LocalContainer, LocalContainerImage, RemoteContainerImage optional

Worker Service implementation contract

The worker must:

  1. Read WORKER_PAYLOAD from the environment and deserialise it as JobPayload.
  2. Perform its work.
  3. Exit with code 0 on success (ACA marks the execution Succeeded) or non-zero on failure (ACA marks it Failed and may retry per replicaRetryLimit).
  4. For self-hosting: call lifetime.StopApplication() at the end of ExecuteAsync to trigger a clean Generic Host shutdown.

Observability

For AcaJob mode, the runner logs the execution name on start:

ACA Job execution started. ExecutionName=myjob--abc123 ExecutionId=... JobId=...
To verify worker logs, query Log Analytics: ContainerAppConsoleLogs_CL | where ContainerGroupName_s startswith 'myjob--abc123' | project TimeGenerated, Log_s

The execution name is the short ARM resource name derived from the execution ID. Use it to filter Log Analytics output directly in Azure Portal or via az containerapp job execution logs show.


Key design decisions

  • Fire-and-forget: AcaJob mode uses WaitUntil.Started, so the runner returns as soon as Azure accepts the start request. The host app's HTTP request is not held open while the worker runs. Work outcome must be tracked separately (database, correlation ID, Log Analytics query).
  • Single façade, multiple runners: The caller only injects IContainerJobRunnerFacade. It never talks to runner implementations directly.
  • Fail-fast validation: ContainerJobRunnerFacade validates required properties for the chosen mode before delegating. Missing config throws InvalidOperationException with a clear message.
  • ArmClient reuse: Registered as TryAddSingleton, so the host app's existing ArmClient (if any) is used rather than creating a duplicate.

Showing the top 20 packages that depend on Substantive.Infrastructure.ContainerJobRunner.

Packages Downloads
Substantive.Metapackage.Backends
A metapackage contains all Substantive backend building blocks.
1
Substantive.Metapackage.Backends
A metapackage contains all Substantive backend building blocks.
2
Substantive.Metapackage.Backends
A metapackage contains all Substantive backend building blocks.
3
Substantive.Metapackage.Backends
A metapackage contains all Substantive backend building blocks.
4

Version Downloads Last updated
9.17.34-build20260919R3 1 09/19/2026
9.17.34-build20260919R2 1 09/19/2026
9.17.34-build20260914R1 2 09/14/2026
9.17.33-build20260715R1 8 07/15/2026
9.17.32-build20260701R1 8 07/01/2026