Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

C# and .NET

The C# SDK, the OrbitLang.Sdk NuGet package, runs ORBIT task handlers on .NET. It depends only on the base class library (System.Net.Http and System.Text.Json). Handlers are asynchronous and typed with System.Text.Json, or work on raw JsonElement values, and each attempt gets a CancellationToken. The SDK handles registration, long polling, heartbeats, local deadlines, result delivery through runtime outages, and graceful shutdown when the token passed to RunAsync is cancelled. Read How workers work first for the concepts this page relies on.

Install

dotnet add package OrbitLang.Sdk

The package targets .NET 8.

A minimal worker

This worker implements greeting.csharp from examples/csharp_hello.orbit:

task "greeting.csharp" {
    version "1.0.0"
    input { name: String, excitement: Int }
    output { message: String, length: Int, dotnet_version: String }
}
using System.Runtime.InteropServices;
using OrbitLang.Sdk;

using var worker = new Worker(new WorkerOptions { WorkerId = "hello-csharp" });

worker.Task<GreetInput, GreetOutput>("greeting.csharp", "1.0.0", async (input, ctx) =>
{
    if (string.IsNullOrWhiteSpace(input.Name))
        throw TaskException.Permanent("INVALID_NAME", "name must not be empty");
    if (input.Excitement is < 0 or > 10)
        throw TaskException.Permanent("INVALID_EXCITEMENT", "excitement must be between 0 and 10");
    await Task.Delay(10, ctx.CancellationToken);
    var message = $"Hello, {input.Name.Trim()}{new string('!', input.Excitement)}";
    ctx.Log($"composed greeting ({message.Length} chars)");
    return new GreetOutput(message, message.Length, Environment.Version.ToString());
});

using var stop = new CancellationTokenSource();
Console.CancelKeyPress += (_, e) => { e.Cancel = true; stop.Cancel(); };
using var sigterm = PosixSignalRegistration.Create(PosixSignal.SIGTERM, c => { c.Cancel = true; stop.Cancel(); });
await worker.RunAsync(stop.Token);

record GreetInput(string Name, int Excitement = 1);
record GreetOutput(string Message, int Length, string DotnetVersion);

The default JSON options use snake_case names, so DotnetVersion is written as dotnet_version, matching the contract. Run the worker against a local runtime; the repository’s examples/workers/csharp project contains the same worker:

orbit server &
dotnet run --project examples/workers/csharp &
orbit run examples/csharp_hello.orbit --input '{"name": "Ada", "excitement": 2}'
{
  "message": "Hello, Ada!!",
  "length": 12
}

Registering tasks

Worker Task<TIn, TOut>(string name, string version, Func<TIn, TaskContext, Task<TOut>> handler)
Worker Task(string name, string version, Func<JsonElement, TaskContext, Task<object?>> handler)

Each overload registers a handler for name@version and returns the worker, so calls can be chained. The name and version must match the task declaration in your .orbit files exactly. Registering the same name twice, or registering while the worker runs, throws InvalidOperationException.

Handlers and results

The typed overload deserializes the input into TIn with the worker’s JsonSerializerOptions. The default options use JsonNamingPolicy.SnakeCaseLower with case-insensitive reads, so OrderId maps to order_id. Override them with WorkerOptions.JsonOptions. Input that cannot be decoded fails the attempt with INVALID_INPUT, which is not retried.

The raw overload receives the input as a JsonElement ({} when the runtime sent none) and may return any JSON-serializable value, such as an anonymous object, a JsonObject, or a JsonElement:

worker.Task("inventory.release", "1.0.0", (input, ctx) =>
    Task.FromResult<object?>(new { released = input.GetProperty("reservation_id").GetString() }));
  • Returning null reports {}.
  • A result that does not serialize to a JSON object fails the attempt with INVALID_RESULT, which is not retried.
  • The runtime validates the output against the contract’s output schema; a mismatch fails the step with OUTPUT_CONTRACT_VIOLATION.

Throwing task errors

Throw TaskException to fail an attempt with a structured error:

throw TaskException.Permanent("CARD_DECLINED", "limit exceeded", new { limit = 5000 });
throw TaskException.Transient("BANK_UNAVAILABLE", "upstream timed out");
throw new TaskException("CARD_DECLINED", "limit exceeded", retryable: false);
Constructor or factoryRetryable
new TaskException(string code, string message, bool retryable = true, object? details = null)as given
TaskException.Transient(string code, string message, object? details = null)yes
TaskException.Permanent(string code, string message, object? details = null)no, fails the step whatever its retry policy

details is serialized with the worker’s JSON options and stored with the error in the execution history. Any other exception is reported as a retryable UNHANDLED_EXCEPTION whose message is <Type>: <message>, with details.type and details.stack. The SDK also logs it.

TaskContext

Property or methodTypeMeaning
TokenstringIdentifies this delivery. The SDK echoes it in the result.
ExecutionIdstringThe workflow execution.
WorkflowstringWorkflow name.
WorkflowVersionstringWorkflow version.
StepIdstringThe step, or compensate:<step> for a compensation; step[i] for a fan-out element.
TaskNamestringThe task name.
ContractVersionstringThe task’s contract version.
InputJsonElementThe raw input.
IdempotencyKeystringStable across retries and redeliveries. Use it for side effects.
Attemptint1-based attempt number. Redeliveries keep the number.
Redeliverybooltrue when the outcome of an earlier delivery of this attempt is unknown.
DeadlineMslong?Absolute deadline in Unix epoch milliseconds, when the step has a timeout.
TimeoutMslong?The step’s timeout in milliseconds.
CancellationTokenCancellationTokenCancelled when the attempt should stop.
RemainingTimeSpan?Time left before DeadlineMs, never negative.
Log(string message)voidLogs message prefixed with [<execution>/<step>#<attempt>].

Cancellation and timeouts

ctx.CancellationToken is cancelled when any of these happens:

  • the runtime lists the attempt in a heartbeat’s cancel list, because the attempt timed out or its workflow was cancelled or failed;
  • the attempt’s DeadlineMs passes;
  • the token passed to RunAsync is cancelled.

Pass the token to every asynchronous call that accepts one:

worker.Task<FetchInput, FetchOutput>("reports.fetch", "1.0.0", async (input, ctx) =>
{
    using var response = await http.GetAsync(input.Url, ctx.CancellationToken);
    return new FetchOutput((int)response.StatusCode);
});

An OperationCanceledException that escapes the handler after the token was cancelled is reported as a retryable CANCELLED, with a message that says whether the runtime cancelled the attempt, the deadline passed, or the worker is shutting down. Heartbeats run on their own task, so a long handler keeps its session alive.

Concurrency

WorkerOptions.Concurrency (default 4) is the number of poll loops, each started with Task.Run, and therefore the maximum number of tasks this worker runs at once. Handlers can run in parallel on thread-pool threads, so make shared state thread-safe.

Credentials and TLS

Every connection setting has a WorkerOptions property and an environment variable. A non-empty property wins.

PropertyEnvironmentDefaultMeaning
ServerORBIT_SERVERhttp://127.0.0.1:7233Runtime URL.
TokenORBIT_WORKER_TOKEN, then ORBIT_TOKENnoneBearer token.
CaCertFileORBIT_CA_CERTnonePEM CA certificates, trusted in addition to the system store.
ClientCertificatePathORBIT_CLIENT_CERTnonePEM client certificate for mutual TLS.
ClientKeyPathORBIT_CLIENT_KEYthe certificate filePEM private key: PKCS#8, PKCS#1 or EC.
using var worker = new Worker(new WorkerOptions
{
    WorkerId = "payments-1",
    Server = "https://orbit.internal:7233",
    CaCertFile = "/etc/orbit/ca.pem",
    ClientCertificatePath = "/etc/orbit/payments-1.pem",
    ClientKeyPath = "/etc/orbit/payments-1.key",
});

The SDK loads the certificate with X509Certificate2.CreateFromPemFile and re-imports it through PKCS#12, so that TLS stacks that reject ephemeral keys accept it. A key without a certificate, a certificate without a key, or a CA file without certificates throws from the constructor. With a per-worker identity from a workers file, set WorkerId to the identity’s id and the token to the identity’s token.

Logging

SDK messages go to WorkerOptions.Logger, an Action<string>. The default writes to standard error with the prefix [orbit] and a timestamp. To use Microsoft.Extensions.Logging, pass a delegate:

var options = new WorkerOptions { Logger = line => logger.LogInformation("{Line}", line) };

ctx.Log writes through the same delegate.

Shutdown

RunAsync returns after the token passed to it is cancelled. It then aborts the open long polls and cancels the CancellationToken of each running handler. It waits for the handlers to finish and delivers their results (retrying for up to 10 minutes), unregisters, and returns. The worker implements IDisposable; dispose it after RunAsync returns.

A handler that ignores its token finishes its work and its result is delivered. A handler that stops because of the token reports a retryable CANCELLED, and that failure counts as an attempt. With the default retry policy of one attempt, the step then fails. Give steps served by .NET workers a retry policy with more than one attempt if you restart workers while they run tasks.

API reference

Worker

public sealed class Worker : IDisposable
public Worker(WorkerOptions? options = null)
MemberMeaning
Task<TIn, TOut>(name, version, handler), Task(name, version, handler)Register handlers. See Registering tasks.
Task RunAsync(CancellationToken cancellationToken = default)Registers, polls and completes after shutdown. Throws InvalidOperationException when no task is registered or the worker is already running, and OrbitApiException when the runtime refuses registration. Connection errors and 5xx responses during registration are retried; cancelling the token while the runtime is unreachable returns without an error.
void Dispose()Releases the HTTP client and the client certificate.
WorkerId, Server, Runtime, Concurrency, PollWaitThe resolved settings.
ClientCertificateThe X509Certificate2 presented for mutual TLS, or null.
VersionThe SDK version, "0.1.0".

WorkerOptions

PropertyTypeDefaultMeaning
WorkerIdstring?csharp-<machine>-<pid>Stable identity sent at registration.
Serverstring?ORBIT_SERVER, then http://127.0.0.1:7233Runtime URL.
Tokenstring?ORBIT_WORKER_TOKEN, then ORBIT_TOKENBearer token.
CaCertFilestring?ORBIT_CA_CERTPEM CA certificate path.
ClientCertificatePathstring?ORBIT_CLIENT_CERTPEM client certificate path.
ClientKeyPathstring?ORBIT_CLIENT_KEYPEM private key path.
Concurrencyint4Number of poll loops; values below 1 become 1.
Runtimestring"csharp"Announced runtime, matched against using <language>.
PollWaitTimeSpan20 secondswait_ms of each long poll. The runtime caps it at 60 seconds.
JsonOptionsJsonSerializerOptions?snake_case, case-insensitiveOptions for input, output and details.
LoggerAction<string>?standard errorDestination of SDK messages.

TaskException

Properties: Code, Message, Retryable, Details. See Throwing task errors.

OrbitApiException

Thrown for an error response or a connection failure. Properties: Status (0 for a connection failure), Code (the runtime’s error code, CONNECTION, or HTTP_ERROR), IsConnectionError.

TaskContext

See TaskContext. The SDK creates it; you do not construct one.

Testing

Keep handler logic in methods you can call directly. The SDK constructs TaskContext, so test the logic without it:

static GreetOutput Greet(GreetInput input) => ...;

[Fact]
public void GreetsWithExcitement() =>
    Assert.Equal("Hello, Ada!!", Greet(new GreetInput("Ada", 2)).Message);

For an integration test, start orbit server with a temporary database, deploy your workflow, start worker.RunAsync(cts.Token) without awaiting it, drive executions through the HTTP API, then cancel cts and await the task. A short PollWait keeps polls responsive, and a Logger that collects lines lets you assert on SDK messages. The SDK’s own tests in sdks/csharp run the worker against a scripted fake runtime built on HttpListener, including mutual TLS; run them with dotnet test.

See also