Croniq.Runner.Sdk
0.9.0
dotnet add package Croniq.Runner.Sdk --version 0.9.0
NuGet\Install-Package Croniq.Runner.Sdk -Version 0.9.0
<PackageReference Include="Croniq.Runner.Sdk" Version="0.9.0" />
<PackageVersion Include="Croniq.Runner.Sdk" Version="0.9.0" />
<PackageReference Include="Croniq.Runner.Sdk" />
paket add Croniq.Runner.Sdk --version 0.9.0
#r "nuget: Croniq.Runner.Sdk, 0.9.0"
#:package Croniq.Runner.Sdk@0.9.0
#addin nuget:?package=Croniq.Runner.Sdk&version=0.9.0
#tool nuget:?package=Croniq.Runner.Sdk&version=0.9.0
Croniq Runner SDK for .NET
Build job execution runners for Croniq in .NET. The SDK polls a Croniq server for work, dispatches typed handlers, streams structured logs back, and reports completion — all with idiomatic Generic Host integration.
Install
dotnet add package Croniq.Runner.Sdk
# optional: tracing + metrics
dotnet add package Croniq.Runner.Sdk.OpenTelemetry
Target frameworks: net8.0, net10.0.
Quick start (Worker Service / .NET 10 top-level statements)
using Croniq.Runner.Sdk;
using Croniq.Runner.Sdk.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
var builder = Host.CreateApplicationBuilder(args);
builder.Services
.AddCroniqRunner(builder.Configuration.GetSection(CroniqRunnerOptions.SectionName))
.AddCroniqJob("hello:world", async (ctx, ct) =>
{
ctx.Logger.LogInformation("Hello from {Job} (attempt {Attempt})", ctx.JobKey, ctx.Attempt);
await Task.Delay(TimeSpan.FromSeconds(1), ct);
});
await builder.Build().RunAsync();
appsettings.json:
{
"Croniq": {
"Runner": {
"ServerUrl": "http://localhost:4000",
"RunnerIdPrefix": "demo-runner",
"ApiKey": "croniq_…",
"MaxInflight": 5,
"Capabilities": [ "demo" ],
"Tags": [ "lang=dotnet", "env=dev" ]
}
}
}
Transport security
The API key is attached to every request as an Authorization header. Over http:// it travels in cleartext — and through any HTTP proxy the environment configures.
CroniqRunnerOptions and CroniqClientOptions therefore refuse a cleartext ServerUrl during options validation (i.e. at host startup, thanks to ValidateOnStart()) unless the host is loopback:
- accepted: any
https://URL, andhttp://onlocalhost,127.0.0.0/8or::1— so thehttp://localhost:4000quickstart default keeps working; - refused:
http://on any other host, with anOptionsValidationExceptionnaming the URL and the opt-in.
If a deployment genuinely has no TLS terminator (a lab or staging box), opt in explicitly — the host then starts, but the SDK logs one loud warning under the Croniq.Runner.Sdk.Security category:
{
"Croniq": {
"Runner": {
"ServerUrl": "http://croniq.internal:4000",
"AllowInsecureHttp": true
}
}
}
The same AllowInsecureHttp switch exists on Croniq:Client for the trigger client.
Features
- Generic Host integration —
IHostedServiceadapter, graceful shutdown viaIHostApplicationLifetime. - Two handler styles:
- delegate:
AddCroniqJob("key", async (ctx, ct) => …) - DI-friendly interface:
AddCroniqJob<MyHandler>("key")withICroniqJobHandler
- delegate:
- Server-side cancellation —
PollResponse.cancelis wired into per-executionCancellationToken. - Streaming log writer —
ctx.LogWriterbacks ontoSystem.Threading.Channelswith backpressure, batching (32 events / 200 ms / max 100 per POST), drain-before-ack. - Self-registration —
AddCroniqJob<T>("key", schedule: "5m")callsPOST /v1/jobs/registeron startup. - Health checks —
services.AddHealthChecks().AddCroniqRunnerHealthCheck(). - OpenTelemetry — opt-in via
tracerBuilder.AddCroniqRunnerInstrumentation()(separate package). - Shell-exec handler — handles DSL
runner shell { … }/runner exec { … }jobs by decoding__runner_execmetadata and spawning a subprocess; stdout/stderr is streamed via the log writer. Register it scoped to explicit job keys (preferred) or as an opt-in catch-all — see Shell-exec jobs. - Producer-side trigger client —
AddCroniqClient(...)+ICroniqTriggerClient.TriggerAsync(...)wrapPOST /v1/triggerwith separate credentials (jobs:triggerscope) and optional idempotency keys. - Trim- and AOT-compatible — the package declares
IsAotCompatible/IsTrimmableand passes the trim/AOT analyzers. The DI/options layer stays reflection-free: source-generated JSON (JsonSerializerContext), source-generatedIConfigurationbinding, and source-generated[OptionsValidator]validation (no reflection-basedValidateDataAnnotations). Register interface handlers withAddCroniqJob<T>(...)and the trimmer preserves their constructors automatically.
Triggering jobs on demand (producer client)
Besides the runner (consumer) side, the SDK ships a first-class trigger client wrapping POST /v1/trigger. It lets application code fire a registered job in response to an event — the same handler then serves both the Croniqfile schedule (reconcile floor) and near-real-time event-driven execution:
builder.Services.AddCroniqClient(builder.Configuration.GetSection(CroniqClientOptions.SectionName));
// anywhere via DI:
public sealed class SignupService(ICroniqTriggerClient croniq)
{
public async Task OnSignupAsync(string userId, CancellationToken ct)
{
var result = await croniq.TriggerAsync(
"crm:welcome-mail",
metadata: new Dictionary<string, string> { ["user_id"] = userId },
idempotencyKey: $"signup-{userId}",
cancellationToken: ct);
// result.ExecutionId, result.Queued, result.Deduplicated
}
}
appsettings.json:
{
"Croniq": {
"Client": {
"ServerUrl": "http://localhost:4000",
"ApiKey": "croniq_…"
}
}
}
Notes:
AddCroniqClientis independent ofAddCroniqRunner— register either or both. Like the runner registration, it is idempotent.- Triggering requires the
jobs:trigger(oradmin) scope, which runner poll keys typically do not carry — the client therefore uses its own credentials (Croniq:Clientsection) instead of the runner's. idempotencyKeyenables server-side dedup of at-least-once producers (repeat triggers with the same key coalesce onto the existing execution and returnDeduplicated = true); servers without support ignore the field.
Shell-exec jobs (runner shell / runner exec)
The SDK ships a handler for DSL runner shell { … } / runner exec { … } jobs: it decodes the __runner_exec metadata the Croniqfile compiler attaches to the work assignment and spawns a subprocess, streaming stdout/stderr through the log writer. Because the command comes from the server, registering this handler is an explicit trust decision — prefer scoping it to the job keys you actually intend to run through a shell:
builder.Services.AddCroniqRunner(...)
.AddCroniqShellHandler("deploy:run", "deploy:cleanup"); // shell-exec for these keys only
The parameterless form registers the handler as the catch-all default — any job key the server dispatches to this runner is executed as a subprocess. That is the .NET equivalent of running the generic Rust croniq-shell-runner and remains supported as a deliberate opt-in:
.AddCroniqShellHandler(); // catch-all: every dispatched job becomes a subprocess
Guard rails:
- Quoting — on POSIX the command string is handed to
/bin/sh -cas a single argv entry viaArgumentList(no escaping round-trip); on Windows it is passed through tocmd.exe /cverbatim, becausecmdparses the remainder of the line itself. userdirective fails closed — .NET cannot switch the subprocess user, so a payload that setsuserfails the execution withuser directive is not supported by the .NET shell handlerinstead of silently running as the runner's own user. Run the runner process as the desired user, or use the Rustcroniq-shell-runner, which honours numeric uids.- Environment guard — payload-supplied
envnames that can hijack process resolution or library loading (PATH,PATHEXT,COMSPEC,LD_PRELOAD,LD_LIBRARY_PATH, anything starting withDYLD_) or collide with the SDK's own configuration namespace (anything starting withCRONIQ_) fail the execution. The comparison is case-insensitive. If the runner fully trusts its server, opt out explicitly:
.AddCroniqShellHandler(o => o.AllowUnsafeEnvironment = true, "deploy:run");
Capabilities vs Tags
A common pitfall: don't put implementation details into capabilities. Capabilities drive job routing (require/prefer in the Croniqfile). Tags are filter-only — for the UI and operations, not routing.
| Good capability | Bad capability |
|---|---|
billing, reporting, gpu, sandboxed |
dotnet, python, linux-x64 |
If your runner is .NET-based, put that into tags (lang=dotnet, platform=linux-x64) so a future Rust- or Python-runner with the same business capabilities can take over without rewriting Croniqfile entries.
DI-friendly handler example
public sealed class BillingInvoiceHandler(
ILogger<BillingInvoiceHandler> logger,
IInvoiceService invoices) : ICroniqJobHandler
{
public async Task HandleAsync(ExecutionContext ctx, CancellationToken cancellationToken)
{
var customerId = ctx.Metadata.GetProperty("customer_id").GetString();
logger.LogInformation("Generating invoice for {Customer}", customerId);
await using var writer = ctx.LogWriter;
await foreach (var line in invoices.GenerateAsync(customerId!, cancellationToken))
await writer.WriteAsync(LogLevel.Information, line, ct: cancellationToken);
}
}
builder.Services.AddCroniqRunner(...)
.AddCroniqJob<BillingInvoiceHandler>("billing:invoice", schedule: "5m");
OpenTelemetry
builder.Services.AddOpenTelemetry()
.WithTracing(t => t.AddCroniqRunnerInstrumentation().AddOtlpExporter())
.WithMetrics(m => m.AddCroniqRunnerInstrumentation().AddOtlpExporter());
Span name: croniq.execute {job_key}. Standard attributes: croniq.job.key, croniq.execution.id, croniq.execution.attempt, croniq.runner.id, croniq.execution.outcome.
Wire-protocol conformance
The SDK is validated against the shared, language-neutral conformance suite at sdks/conformance/ — 12 YAML cases pinning poll/ack/renew, server-initiated cancel, drain, lease renewal, streaming logs, auth, self-register, and error handling. Future Python / Go / TypeScript / Java SDKs are expected to pass the same cases. Run them locally with:
dotnet test sdks/dotnet/tests/Croniq.Runner.Sdk.Conformance.Tests
When the wire protocol gains a new behaviour, the case is added to sdks/conformance/cases/ first — that way every SDK author has a single artifact describing the contract change.
Compatibility matrix
| SDK Version | Croniq Server (min) | Croniq Server (max tested) |
|---|---|---|
| 0.1.x | 0.14.0 | 0.14.0 |
Releasing
Publishing is automated by .github/workflows/dotnet-sdk-release.yml. To ship a new version:
- Update the CHANGELOG (the package version itself comes from MinVer — no
.csprojedit needed). - Merge to
main. - Tag the commit:
git tag dotnet-sdk-v0.2.0 && git push --tags.
MinVer (configured in Directory.Build.props with prefix dotnet-sdk-v) derives the package version from the tag, so both Croniq.Runner.Sdk and Croniq.Runner.Sdk.OpenTelemetry pack with the right number automatically. Pre-release suffixes pass through unchanged (e.g. dotnet-sdk-v0.2.0-preview.1).
The workflow restores, builds, runs unit tests on net8.0+net10.0, re-runs the full conformance suite, then dotnet nuget push both .nupkg + .snupkg symbol packages to nuget.org.
Prerequisite: a repo admin must set the NUGET_API_KEY secret once (generate at https://www.nuget.org/account/apikeys, scoped to the Croniq.* package glob with "Push new packages and package versions").
Pre-release feed (GitHub Packages)
Add to nuget.config:
<add key="croniq-github" value="https://nuget.pkg.github.com/nuetzliches/index.json" />
A GitHub PAT with read:packages scope is required for restore.
License
Dual-licensed under MIT OR Apache-2.0. See LICENSE-MIT and LICENSE-APACHE.
| Product | Versions Compatible and additional computed target framework versions. |
|---|---|
| .NET | net8.0 is compatible. net8.0-android was computed. net8.0-browser was computed. net8.0-ios was computed. net8.0-maccatalyst was computed. net8.0-macos was computed. net8.0-tvos was computed. net8.0-windows was computed. net9.0 was computed. net9.0-android was computed. net9.0-browser was computed. net9.0-ios was computed. net9.0-maccatalyst was computed. net9.0-macos was computed. net9.0-tvos was computed. net9.0-windows was computed. net10.0 is compatible. net10.0-android was computed. net10.0-browser was computed. net10.0-ios was computed. net10.0-maccatalyst was computed. net10.0-macos was computed. net10.0-tvos was computed. net10.0-windows was computed. |
-
net10.0
- Microsoft.Extensions.Diagnostics.HealthChecks (>= 10.0.0)
- Microsoft.Extensions.Diagnostics.HealthChecks.Abstractions (>= 10.0.0)
- Microsoft.Extensions.Hosting.Abstractions (>= 10.0.0)
- Microsoft.Extensions.Http (>= 10.0.0)
- Microsoft.Extensions.Logging.Abstractions (>= 10.0.0)
- Microsoft.Extensions.Options (>= 10.0.0)
- Microsoft.Extensions.Options.ConfigurationExtensions (>= 10.0.0)
- Microsoft.Extensions.Options.DataAnnotations (>= 10.0.0)
-
net8.0
- Microsoft.Extensions.Diagnostics.HealthChecks (>= 10.0.0)
- Microsoft.Extensions.Diagnostics.HealthChecks.Abstractions (>= 10.0.0)
- Microsoft.Extensions.Hosting.Abstractions (>= 10.0.0)
- Microsoft.Extensions.Http (>= 10.0.0)
- Microsoft.Extensions.Logging.Abstractions (>= 10.0.0)
- Microsoft.Extensions.Options (>= 10.0.0)
- Microsoft.Extensions.Options.ConfigurationExtensions (>= 10.0.0)
- Microsoft.Extensions.Options.DataAnnotations (>= 10.0.0)
NuGet packages (1)
Showing the top 1 NuGet packages that depend on Croniq.Runner.Sdk:
| Package | Downloads |
|---|---|
|
Croniq.Runner.Sdk.OpenTelemetry
OpenTelemetry tracing and metrics instrumentation for the Croniq Runner SDK. Provides ActivitySource and Meter constants plus TracerProviderBuilder/MeterProviderBuilder extensions. |
GitHub repositories
This package is not used by any popular GitHub repositories.