Table of Contents
Audience: Matrix42 partners and customers building custom extensions on the Enterprise Service Management platform.
Overview
Queue Service is the platform's asynchronous execution SDK. Instead of running your customization code inline on a web request thread, you package it as a .NET assembly, describe what to run and how (as a TaskDescriptor), and hand it to the queue. A Dispatcher — a worker process that may be running locally next to the platform or remotely on its own agent host — picks up the task, executes your code, and (optionally) hands the result back to you through a lightweight publish/subscribe channel.
Use it whenever your customization needs to:
- run outside the lifetime of the original HTTP request (long-running work, background jobs),
- be retried/recovered if a worker restarts mid-execution,
- run on a specific class of worker (e.g. one with database access, or a Linux host), or
- coordinate completion between two independent pieces of code without polling a database.
The SDK is consumed via three small building blocks, resolved through the platform's dependency injection container:
| Type | Package | Purpose |
|---|---|---|
IQueueClient |
Matrix42.QueueProxy.Contracts (impl: Matrix42.Queue.Client) | Enqueue a task for a Dispatcher to execute. |
IEventBusFactory |
Matrix42.EventBus.Contracts | Get a publish/subscribe bus to await — or produce — an async result |
| Topics | Matrix42.EventBus.Contracts | Static helper for building the topic names IEventBus routes on. |
public class MyExtension
{
private readonly IQueueClient _queueClient;
private readonly IEventBusFactory _eventBusFactory;
public MyExtension(IQueueClient queueClient, IEventBusFactory eventBusFactory)
{
_queueClient = queueClient;
_eventBusFactory = eventBusFactory;
}
}Core Concepts
Four building blocks make up every queued task: what to run, how to run it, where its result goes, and who's allowed to run it.
The task contract: TaskDescriptor
A queued task is, at its core, a description of a .NET method to invoke — your extension's entry point:
public sealed class TaskDescriptor
{
public string AssemblyPath; // path to the DLL containing your type — see note below on its real shape
public string AssemblyName; // assembly display name, e.g. "Matrix42.MyExtension"
public string TypeName; // fully-qualified type to instantiate, e.g. "Matrix42.MyExtension.SomeWorkflow"
public string MethodName; // method to invoke, e.g. "Run"
public string MethodParams; // JSON-encoded arguments — deserialized to match your method's parameters
}When the Dispatcher dequeues the task, it loads AssemblyPath, instantiates TypeName, and invokes MethodName, passing the deserialized MethodParams. This is the actual extensibility surface: any public method on any type in your deployed assembly can be a queued task target — there is no base class or interface to implement.
var descriptor = new TaskDescriptor
{
AssemblyPath = "InstalledPackages/Assemblies/{your-package-id}/{version}/Matrix42.MyExtension.dll",
AssemblyName = "Matrix42.MyExtension",
TypeName = "Matrix42.MyExtension.SomeWorkflow",
MethodName = "Run",
MethodParams = JsonConvert.SerializeObject(new
{
dbRecord = new { EntityName = "MyEntity", Id = Guid.NewGuid() },
options = new { RetryCount = 3 }
})
};AssemblyPath is filled in for you once your extension is installed as a sandboxed Matrix42 package — the installer resolves it to InstalledPackages/Assemblies/{PackageId}/{PackageVersion}/{AssemblyName}.dll automatically. Don't hand-construct this path yourself, and note it only applies to extensions packaged and installed this way, not a manual DLL drop into some other folder.
Your method also executes inside a separate Dispatcher process — possibly a remote agent on an entirely different machine than whatever enqueued the task. Treat it as a self-contained unit of work: everything it needs must come in through MethodParams, since it can't rely on the caller's in-memory state, HttpContext.Current, or any other request-scoped context.
Execution model: TaskContext
Alongside the descriptor, a TaskContext tells the platform how to run it:
public sealed class TaskContext
{
public FunctionType FunctionType { get; set; } = FunctionType.Unknown;
public Guid CorrelationId { get; set; } = Guid.NewGuid();
public Guid ProcessId { get; set; } = Guid.NewGuid();
public WorkerType WorkerType { get; set; } = WorkerType.RealTime; // RealTime or Isolated
public DataLayerMode DataLayerMode { get; set; } = DataLayerMode.Minimal;
}The two identifier fields matter once more than one task is involved:
-
CorrelationId — an identifier that travels with the task into its engine parameters (visible to execution logs/diagnostics) and is what you feed into Topics.For to build the task's result topic (§2.3). Give the same
CorrelationIdto every task that's part of one logical business transaction — see §3.2 for a worked example. -
ProcessId — identifies the overarching process/workflow instance a task belongs to, independent of
CorrelationId. Set it the same way if your extension already has a natural "instance id" to propagate (e.g. from the Workflow engine).
Both default to a fresh, random Guid per call if you don't set them.
If you don't set TaskContext at all, EnqueueTask defaults to RealTime + DataLayerMode.Minimal + the modern executor — a sensible default for short extension code.
More details about Worker Types and DataLayer Modes
Capabilities: how a task finds a Dispatcher
A Dispatcher (worker) advertises a set of capabilities — well-known GUIDs describing what it's able to run:
| Capability | Meaning |
|---|---|
| TaskCapabilities.HasDbAccess ("Server") | Dispatcher has direct database access |
| TaskCapabilities.Workflows | Dispatcher runs the Workflow engine |
| TaskCapabilities.EMailSending | Dispatcher can send email |
| TaskCapabilities.IsLinuxSystem / IsWindowsSystem | Dispatcher's OS |
| TaskCapabilities.LegacyActivation | Dispatcher supports legacy Engine activations |
Read more about Functional Worker Capabilities
A QueueTask carries a RequiredCapabilities array; a Dispatcher only dequeues tasks whose required capabilities it has all been configured with (matched server-side against the Dispatcher's registered capability set). This is how the platform's own built-in task kinds get routed to the right kind of worker — for example, every task the Workflow engine enqueues is stamped with RequiredCapabilities = [TaskCapabilities.Workflows], so only a Dispatcher configured to run workflows will ever pick it up.
Practical takeaway: if your extension has no special infrastructure requirements, you don't need to think about capabilities at all — it lands on the default pool of whichever Dispatcher(s) are online. If your extension does need to run only on Dispatchers with a particular capability, how that capability ends up assigned depends on which one it is:
Enqueuing Tasks
With the building blocks in hand, here's how to actually call EnqueueTask — including how to correlate several calls into one business transaction and how to cancel one in flight.
Enqueuing a task
The primary SDK entry point is IQueueClient.EnqueueTask:
Task EnqueueTask(TaskDescriptor descriptor, string topic, TaskContext context = null,
int? timeoutMs = null, CancellationToken ct = default);topic — the pub/sub topic the executor will publish its result to when it's done. Build one with Topics.For(namespace, correlationId). You choose this even if you don't intend to read the result — see the “fire-and-forget” pattern below.
context — setup how to run task. Omit for RealTime defaults.
timeoutMs — how long the executor will let your method run before giving up (defaults to QueueClient.DefaultTimeoutMs).
ct — if cancellable, cancellation is bridged to the executor.
var correlationId = Guid.NewGuid();
var topic = Topics.For(Topics.Namespaces.Generic, correlationId);
await _queueClient.EnqueueTask(descriptor, topic, new TaskContext
{
WorkerType = WorkerType.Isolated,
DataLayerMode = DataLayerMode.Full,
CorrelationId = correlationId
});EnqueueTask itself only enqueues — it does not wait for your method to finish. Whether (and how) you observe completion is a separate, opt-in step, described next.
Correlating multiple tasks in one business transaction
A business transaction often isn't one queued task — it's several, possibly running on different Dispatchers, in different WorkerType tiers, at different times. TaskContext.CorrelationId is how you tie them back together: give every task that's logically part of the same operation the same CorrelationId, even though each is enqueued and executed completely independently.
That value is stamped into each task's engine parameters, so anything that surfaces them — execution logs, monitoring, diagnostics — can group every task belonging to one transaction under a single Guid, without needing a shared database record of your own.
Example — onboarding a new employee is one business transaction made of two independent queued tasks: provisioning a mailbox, and sending a welcome email. Both share a CorrelationId; each still needs its own result topic, built with Topics.For's suffix parameter— reusing the identical topic for both would collide, since topics are single-shot:
var correlationId = Guid.NewGuid(); // shared across every task in this transaction
var provisionTopic = Topics.For(Topics.Namespaces.Generic, correlationId, "provision-mailbox");
var welcomeTopic = Topics.For(Topics.Namespaces.Generic, correlationId, "send-welcome-email");
using var provisionSub = _eventBusFactory.Get().Subscribe(provisionTopic, new SubscribeOptions { Ttl = TimeSpan.FromMinutes(5) });
using var welcomeSub = _eventBusFactory.Get().Subscribe(welcomeTopic, new SubscribeOptions { Ttl = TimeSpan.FromMinutes(5) });
await _queueClient.EnqueueTask(provisionMailboxDescriptor, provisionTopic, new TaskContext
{
CorrelationId = correlationId,
WorkerType = WorkerType.Isolated // mailbox provisioning can be slower
});
await _queueClient.EnqueueTask(sendWelcomeEmailDescriptor, welcomeTopic, new TaskContext
{
CorrelationId = correlationId,
WorkerType = WorkerType.RealTime // sending the email is quick
});
var provisionResult = await provisionSub.NextAsync(TimeSpan.FromMinutes(5));
var welcomeResult = await welcomeSub.NextAsync(TimeSpan.FromMinutes(5));Both QueueTask rows are independent — different WorkerType, potentially different Dispatchers, no ordering guarantee between them — but because they share one CorrelationId, a support engineer (or your own telemetry) looking at either task's execution log can answer "what else happened as part of this same onboarding operation?"
ProcessId plays a related but distinct role: use it, the same way, when the tasks belong to one overarching process/workflow instance you already track elsewhere (e.g. an id handed to you by the Workflow engine), whereas CorrelationId is the general-purpose field for grouping related tasks for tracing, and later easier monitor them in management area Administration / Workers / Worker Logs
Cancellation
Pass a cancellable CancellationToken to EnqueueTask, and the SDK wires a cancel bridge automatically — no extra code needed:
using var cts = new CancellationTokenSource(TimeSpan.FromMinutes(5));
await _queueClient.EnqueueTask(descriptor, topic, ct: cts.Token);When cts is cancelled, the client publishes to a derived {topic}:cancel topic; the executor listens for it and cancels your method's execution scope. If you never pass a cancellable token, no cancel topic is created.
Usage Patterns
The same EnqueueTask/IEventBusFactory primitives combine into three canonical patterns, depending on whether — and how — you need the result.
Fire-and-forget
Enqueue and move on — don't subscribe to the result at all. The task still gets a topic (required by the API), you simply never read from it:
var descriptor = new TaskDescriptor
{
AssemblyPath = "InstalledPackages/Assemblies/{your-package-id}/{version}/Matrix42.MyExtension.dll",
AssemblyName = "Matrix42.MyExtension",
TypeName = "Matrix42.MyExtension.AuditLogger",
MethodName = "RecordEvent",
MethodParams = JsonConvert.SerializeObject(new { eventName = "UserLoggedIn" })
};
var topic = Topics.For(Topics.Namespaces.Generic, Guid.NewGuid());
// No subscription, nothing awaited beyond the enqueue itself.
await _queueClient.EnqueueTask(descriptor, topic);Good for side effects that don't need a caller to know the outcome: notifications, logging, cache invalidation, cleanup jobs.
Request/response (await one result)
Subscribe before you enqueue — this avoids a race where the executor could publish its result before you start listening:
var bus = _eventBusFactory.Get(); // SubscriptionDurability.Durable by default — survives a host restart
var correlationId = Guid.NewGuid();
var topic = Topics.For(Topics.Namespaces.Workflow, correlationId);
using var subscription = bus.Subscribe(topic, new SubscribeOptions
{
Durability = SubscriptionDurability.Durable,
Ttl = TimeSpan.FromMinutes(5)
});
await _queueClient.EnqueueTask(descriptor, topic, new TaskContext
{
CorrelationId = correlationId,
WorkerType = WorkerType.Isolated
});
// Blocks (asynchronously) until the executor publishes to `topic`, or the TTL elapses.
var result = await subscription.NextAsync(TimeSpan.FromMinutes(5));
if (result == null)
{
// Timed out — the executor either hasn't finished or the message was lost.
}
else
{
var response = result.ToHttpResponse(); // or result.ToObject<MyResultType>(), or result.ToText()
}Your queued method signals completion by publishing to the CallbackTopic it receives (wired automatically from the topic you passed to EnqueueTask) — the platform's task executor does this for you once your method returns.
Publish/subscribe via IEventBusFactory
IEventBusFactory/IEventBus is a general-purpose async rendezvous primitive — it doesn't require the queue at all. Use it any time two pieces of code need to coordinate on a result without polling: awaiting a background Task, coordinating between two request handlers, or building your own async workflow on top of the queue's EnqueueTask primitive.
public interface IEventBusFactory
{
IEventBus Get(SubscriptionDurability durability = SubscriptionDurability.Durable);
}
public interface IEventBus
{
IEventSubscription Subscribe(string topic, SubscribeOptions opts = null);
Task PublishAsync(EventPayload payload, PublishMode mode = PublishMode.Durable, CancellationToken ct = default);
Task<EventPayload> TryGetResultAsync(string topic, CancellationToken ct = default);
}Three durability tiers, chosen per call to Get(...):
| SubscriptionDurability | Scope | Survives restart? | Use for |
|---|---|---|---|
| Volatile | Same process only, in-memory | No | Same-host coordination, lowest overhead |
| Durable (default) | Cross-host, DB-backed | Yes | Queue task results, anything that must survive a restart |
| Streaming | In-process + SSE session affinity | No | Server-Sent-Events style progress/streaming |
Producer/consumer on independent code paths (no queue involved at all):
var factory = _eventBusFactory;
var topic = Topics.For(Topics.Namespaces.Generic, Guid.NewGuid());
// Consumer side
var consumerBus = factory.Get(SubscriptionDurability.Durable);
using var sub = consumerBus.Subscribe(topic);
// Producer side — could be a different request, a different process
var producerBus = factory.Get();
await producerBus.PublishAsync(new EventPayload
{
Topic = topic,
Payload = Encoding.UTF8.GetBytes("plain result"),
ContentType = "text/plain",
PublishedAt = DateTime.UtcNow
}, PublishMode.Durable);
var received = await sub.NextAsync(TimeSpan.FromSeconds(5));
Console.WriteLine(received.ToText()); // "plain result"In-process background work coordinated via the bus (still no queue — just a Task.Run and a Volatile publish):
var subBus = _eventBusFactory.Get(SubscriptionDurability.Volatile);
var topic = Topics.For(Topics.Namespaces.Generic, Guid.NewGuid());
using var sub = subBus.Subscribe(topic, new SubscribeOptions
{
Durability = SubscriptionDurability.Volatile,
Ttl = TimeSpan.FromSeconds(30)
});
var producerBus = _eventBusFactory.Get(SubscriptionDurability.Volatile);
_ = Task.Run(async () =>
{
var computed = await DoSomeWorkAsync();
await producerBus.PublishAsync(new EventPayload
{
Topic = topic, Payload = Encoding.UTF8.GetBytes(computed), ContentType = "text/plain",
PublishedAt = DateTime.UtcNow
}, PublishMode.Volatile);
});
var result = await sub.NextAsync(TimeSpan.FromSeconds(10));Results & Error Handling
Once a result comes back (or doesn't), here's how to decode it and what failure actually looks like.
Decoding results
EventPayload is a plain envelope (Topic, ContentType, Payload bytes, PublishedAt). Extension methods cover the common shapes:
EventPayload payload = await subscription.NextAsync(TimeSpan.FromSeconds(5));
payload.ToText(); // UTF-8 string
payload.ToObject<MyResultType>(); // JSON-deserialize
payload.ToHttpResponse(); // reconstruct an HttpResponseMessage (status, headers, body) — what
// the platform's Function executor publishes for method results
payload.ToBytes(); // raw bytesError handling & timeouts
- If your subscription's NextAsync(ttl) returns null, no result arrived within the TTL — this can mean the task is still running (increase TTL / use a longer-lived subscription), or the result was lost (host restart between execution and delivery, for a Volatile bus).
- Use Durable subscriptions for anything that must survive a process restart; TryGetResultAsync(topic) lets you do a one-shot recovery check for an already-published result without creating a new subscription — useful after your own process restarts mid-wait.
- If your queued method throws, the exception is delivered to your subscriber as an error payload (error message, exception type, stack trace) — not as silence and not as a bare timeout. A caller who never subscribes (the fire-and-forget pattern) still has no signal at all, since there's nothing listening to receive it. If you need to know about failures, use the request/response pattern rather than fire-and-forget.
- The Queue Service does not automatically retry a task whose method genuinely threw. The only retries are pre-invocation dispatch failures (no free executor slot, etc.), and those happen before your method ever runs — they can't cause it to execute twice.
- That said, there's a narrow at-least-once window under pure infrastructure failure (e.g. a status update or lease renewal failing, unrelated to your code) where your method could in principle be invoked more than once for the same task. There's no platform-provided idempotency key to detect this — if duplicate side effects would be harmful, make your method idempotent using your own key (e.g. derived from CorrelationId).
- There's no dead-letter queue exposed to extension code — repeatedly failing tasks are not automatically isolated for inspection beyond internal diagnostics.
Quick Reference
| I want to… | Use |
|---|---|
| Run my extension code asynchronously | IQueueClient.EnqueueTask(descriptor, topic, context) |
| Not care about the result | Enqueue, never subscribe |
| Wait for the result |
IEventBusFactory.Get().Subscribe(topic) before enqueuing, then await sub.NextAsync(ttl)
|
| Build a topic |
Topics.For(namespace, correlationId, suffix) (deterministic) or Topics.New(namespace, suffix) (one-off) |
| Correlate several tasks as one business transaction | Same CorrelationId on every task's TaskContext; distinct topics via the suffix parameter |
| Coordinate two code paths without the queue | IEventBusFactory.Get(durability) directly — PublishAsync / Subscribe |
| Run short, latency-sensitive work | TaskContext.WorkerType = WorkerType.RealTime (default) |
| Run long/heavy work | TaskContext.WorkerType = WorkerType.Isolated |
| Target a specific class of infrastructure (DB access, OS, etc.) | Use Capabilities |
| Support cancellation | Pass a cancellable CancellationToken to EnqueueTask |
| Decode a result | EventPayload.ToText() / .ToObject<T>() / .ToHttpResponse() |