Processors in .NET
This page describes the processor model of the .NET core (decisions D-35 to D-39). The pipeline section below describes what happens to a message; the real transports follow in later sessions.
A payload
A payload is a record marked with [CloudEventType]. The application lists it in one JsonSerializerContext; the generator uses that context for the descriptor.
[CloudEventType("com.example.greetings.hello.v1", Schema = "https://example.com/schemas/hello")]
public sealed record Hello(string Name)
{
[CarriedAs("hiddenstr")] // travels as a CloudEvent attribute, not in the JSON body
[JsonIgnore] // required: keeps it out of the body
public string? HiddenStr { get; init; }
}
[JsonSerializable(typeof(Hello))]
internal sealed partial class AppJsonContext : JsonSerializerContext;
- The record must be a non-generic reference type in the same assembly as the processor.
[CarriedAs(name)]needs a settablestringproperty,[JsonIgnore], and a name of lowercase letters and digits, at most 20 characters (CloudEvents extension attribute rule).- Only the
Jsoncodec is generated.BinaryandOpcUaJsonpayloads use handwritten descriptors andAddPayloads(...).
A processor
[Processor(ProcessorBinding.Southbound, Id = "greetings")]
public sealed partial class GreetingsProcessor(ILogger<GreetingsProcessor> logger)
{
[Incoming]
public IEnumerable<Hello> OnHello(Hello hello, [Scope] string scope) { ... }
[Fallback]
public static EventEnvelope OnUnknown(EventEnvelope raw) { ... }
}
The class is partial; the generator adds Describe() (the IDescribedProcessor interface). There is no base class and no reflection.
| Attribute | Meaning |
|---|---|
[Incoming] |
Handles a request. The first parameter is the payload. For a type an SFC recipe waits for, it may return an ActionOutcome; [Incoming(Outcomes = ...)] names the outcomes a recipe can use. |
[Outgoing(typeof(T))] |
Factory for command payloads, called by type id with a CommandRequest. |
[OnResponse] |
Handles a response to a command this instance sent. May return an ActionOutcome, which is not published and tells a recipe how the action ended; without one the outcome is done. |
[OnExpired] |
Called when a command expired without a response. |
[Fallback] |
Takes events no handler claims. First parameter is the EventEnvelope. |
[OnInitialize], [OnStarted(Singleton = true)], [OnShutdown] |
Lifecycle hooks. |
Parameters: the payload, EventContext, CancellationToken, [Scope] string, [SubjectPart(i)] string, IServiceProvider or any registered service. Returns: a payload, IEnumerable<T>, IAsyncEnumerable<T>, Replies, Task/ValueTask of these, void, or null for no reply. A fallback may return a prebuilt EventEnvelope. Handlers can be static.
Replies take correlationid from the request (or a new one), causationid = request id and the request subject. Attributes the request carried are copied to a reply whose type declares the same [CarriedAs] name.
Registration and configuration
builder.Services
.AddMicroDcs(builder.Configuration)
.AddProcessor<GreetingsProcessor>()
.AddInMemoryTransport(); // real transports arrive with S13 and later
Configuration binds the MicroDcs section with source-generated binding. Environment variables use the prefix APP_ and __ as separator, for example APP_MicroDcs__MessageWorkers=7. Every error in the options ends the process at start (D-27).
| Name | Type | Default | Description |
|---|---|---|---|
MicroDcs:CloudEventSource |
string | none, required | source of events this application creates. |
MicroDcs:InstanceId |
string | POD_ID or a generated id |
Instance id; part of the dedupe token. |
MicroDcs:Roles |
Processor, Publisher, All |
All |
Which parts this instance runs. |
MicroDcs:MessageExpiry |
time span | none | Expiry interval for created events. A command with an expiry interval starts a local timer that [OnExpired] answers. |
MicroDcs:ShutdownGracePeriod |
time span | 30 s | Time for in-flight work on shutdown. |
MicroDcs:OutgoingQueueMaxSize |
int | 1000 | Upper bound of any outgoing queue (D-28). |
MicroDcs:MessageWorkers |
int | 5 | Parallel workers per transport. |
MicroDcs:DedupeLease |
time span | 30 s | Lease of a dedupe claim; must exceed the slowest handler. |
MicroDcs:DedupeRetention |
time span | 10 min | How long a processed event stays a duplicate. |
MicroDcs:StartLeaseTtl |
time span | 30 s | Lifetime of the singleton start lease. |
MicroDcs:Bindings:{id}:Transport |
string | mqtt |
Transport name; the transport must be registered. |
MicroDcs:Bindings:{id}:Prefix |
string | none, required | Address prefix; / separated segments of letters, digits, _, -. |
MicroDcs:Bindings:{id}:ScopeLevels |
int | 0 | Scope levels below the prefix. |
MicroDcs:Bindings:{id}:Discriminator |
string | none | Separates processors that share a prefix; the pair must be unique. |
MicroDcs:Bindings:{id}:ScopeCheck |
Enforce, Off |
Enforce |
Compare subject scope and address scope. |
MicroDcs:Bindings:{id}:Level0Subjects |
Reject, Accept |
Reject |
Subjects on addresses without a scope. |
MicroDcs:Bindings:{id}:OutgoingQueueSize |
int | 0 | Size of the outgoing queue of the binding; 0 means OutgoingQueueMaxSize; a larger value ends the process at start. |
MicroDcs:Bindings:{id}:OutgoingTypeFilter |
string | none | Comma separated event type ids or patterns (*, ?) a binding may publish; empty allows all. |
MicroDcs:Bindings:{id}:AllowedReplyPrefixes |
string | none | Comma separated prefixes a reply address from a peer must start with; empty allows every address the built-in rules accept (D-48). |
ReplyBase and SharedGroup are described with the transports (MQTT, NATS).
A reply address that a peer sends along (MQTT response topic, NATS Mdcs-Reply-To) is checked before the instance publishes to it with its own permissions (D-48). Rejected are addresses that start with $ (broker and server namespaces such as $JS.API or $KV), contain wildcards, white space or empty segments, or lie below a binding prefix of this instance (its own event and state topics). Addresses below one of the instance's reply bases stay allowed. A message with a rejected reply address is dropped before the pipeline and logged in the MicroDCS.Security category without the address. The MicroDcs:Management:* fields (inspection and MCP) are in Inspection and MCP.
Pipeline
One pipeline serves every transport. For a received message the steps run in this order:
- Claim the event id (only on transports with at-least-once delivery). A claim held by another worker is awaited; a processed event is a duplicate and is acknowledged without running the handler.
- Cancel the expiry timer of the command the message answers (
causationid). - Check the scope of the subject against the address (D-38). A mismatch is dropped and logged in the
MicroDCS.Securitycategory. - Decode the payload, run the filters and the handler, run the action sink.
- Publish the replies, to the reply address of the request or to the
eventsaddress. - Mark the event done, then the transport acknowledges.
The pipeline returns one of three outcomes to the transport:
| Outcome | When | Transport action |
|---|---|---|
Ack |
Processed, or a duplicate. | Acknowledge. |
Drop |
Undecodable, no handler for the type, scope rejected. | Acknowledge; the message is not redelivered. A request with a reply address gets an error event of type io.microdcs.error.v1 with the error kind and a fixed message, never the payload. |
Fault |
The handler threw. | Do not acknowledge. The claim is not marked done, so the broker redelivers after the lease. A transport that cannot redeliver in the same session ends the host (D-27). |
Publishing events. Inject IEventPublisher. An event goes to a bounded queue per binding and a pump hands it to the transport, in order; the transport waits while it reconnects. A full queue throws OutboundQueueFullException (D-28). The event goes to every binding whose processor declares the intent and whose type filter accepts the type; PublishOptions.BindingId selects one binding. An intent no processor declares throws. A command carries this instance's reply address.
Expiry. A command with an expiry interval and a reply address starts a timer in this instance when it is published. The response (an event whose causationid is the command id) cancels it. When it fires, the [OnExpired] handler of the processor runs with the original payload and the events it returns are published. A failing expiry handler is logged and does not end the host. Timers are local to the instance (D-9).
Shutdown. Receiving stops, in-flight messages finish, the outgoing queues drain, the transports stop, pending expiry timers are awaited, then the processors' [OnShutdown] hooks run. When the grace period ends the rest is cancelled. Events returned by an [OnExpired] handler during shutdown are not published, because the queues are closed by then.
Telemetry. The activity source and the meter are both named MicroDCS. A message with traceparent and tracestate continues that trace in a consumer activity process {type}; events created while it runs carry the context. Counters: microdcs.messages.received (tag outcome), microdcs.messages.published, microdcs.outbound.rejected, microdcs.commands.expired. Logs carry ids and sizes, never payloads.
Testing a processor
MicroDCS.Testing drives a processor through the real lifecycle and pipeline on the in-memory transport with a fake clock. No broker, no Valkey.
await using var harness = await ProcessorHarness.StartAsync<GreetingsProcessor>();
var result = await harness.SendAsync(new Hello("Bob"), subject: "plant1.line2/res");
result.Published.Length.ShouldBe(2);
harness.Decode<Hello>(result.Published[0]).Name.ShouldBe("Bob");
Diagnostics
The generator reports these errors while you type.
| Id | Meaning |
|---|---|
| MDCS001 | Processor class must be partial, top level and not generic. |
| MDCS002 | Handler without the required first parameter. |
| MDCS003 | Two handlers of one kind for the same type id, or two lifecycle hooks of one kind. |
| MDCS004 | Unsupported parameter. |
| MDCS005 | Unsupported return type. |
| MDCS006 | Payload type without [CloudEventType]. |
| MDCS007 | Invalid [CarriedAs] (not a settable string, no [JsonIgnore], or a bad name). |
| MDCS008 | Two payload types with the same type id. |
| MDCS009 | Payload not listed in a JsonSerializerContext of the assembly. |
| MDCS010 | Codec without a generated descriptor: Binary, or OpcUaJson on a type that is not a generated OPC UA structure with a public parameterless constructor. |
| MDCS011 | Payload type is generic, a value type or nested. |
| MDCS012 | The [Processor] argument does not resolve (for example a missing using for ProcessorBinding). |