Skip to content

Events & Jobs

Two runtime systems let a plugin react to and schedule work: the business-event bus (synchronous, in-process, cancelable) and the job queue (asynchronous, durable, at-least-once with HA safety).

The business-event bus

The bus is the primary way plugins react to platform activity. It is a synchronous publish/subscribe fan-out over the same shared, in-process types every plugin references.

Publishing

The publisher (a host subsystem or a plugin) raises an event through IBusinessEventBus (src/Core/Application/Events/Contracts/):

csharp
public interface IBusinessEventBus
{
    Task PublishAsync(IBusinessEvent businessEvent, CancellationToken cancellationToken = default);
}

An event implements IBusinessEvent — a stable EventName, an optional WorkspaceKey scope (null = platform-wide), and a flat ToEventData() projection for templates and flows:

csharp
public interface IBusinessEvent : IHostEvent
{
    string EventName { get; }        // stable dotted name, e.g. "call.ringing"
    string? WorkspaceKey { get; }    // null = platform-wide
    IReadOnlyDictionary<string, string> ToEventData();
}

Subscribing

A plugin reacts by exporting an IBusinessEventListener (Backend Extensions). The listener is called for every published event and filters on EventName itself:

csharp
public sealed class CallLifecycleListener : IBusinessEventListener
{
    public int Priority => 0;

    public Task OnBusinessEventAsync(IBusinessEvent e, CancellationToken ct = default)
    {
        if (e.EventName == "call.ringing") { /* … */ }
        return Task.CompletedTask;
    }
}

Ordering and cancellation

BusinessEventBus (src/Core/Application/Events/Business/) collects host and plugin listeners, orders them by descending Priority (higher runs first), and dispatches in order. For events that derive from MutableBusinessEvent — the before-commit events — the base InterceptableEvent gives listeners three levers:

  • State — a mutable dictionary to pass data between listeners in one dispatch.
  • StopPropagation() — stop the remaining listeners; the bus checks IsPropagationStopped after each and breaks early.
  • Cancel() — veto the operation; the publisher inspects IsCanceled after PublishAsync and aborts.

A mutable, cancelable event is Callora's equivalent of a Symfony/Shopware "before" event:

csharp
public sealed class BeforeCallDialEvent(string workspaceKey, string number)
    : MutableBusinessEvent("call.before-dial", workspaceKey)
{
    public string Number { get; } = number;
    public override IReadOnlyDictionary<string, string> ToEventData()
        => new Dictionary<string, string> { ["number"] = Number };
}

// A listener can veto:
public Task OnBusinessEventAsync(IBusinessEvent e, CancellationToken ct)
{
    if (e is BeforeCallDialEvent dial && IsBlocked(dial.Number))
        dial.Cancel();
    return Task.CompletedTask;
}

The bus is synchronous and in-process. For work that must survive a restart or run out of band, use the job queue.

The job queue

The job queue runs durable background work with at-least-once delivery and the safety properties needed for horizontal scale: leases, a reaper, idempotency, a fencing token, and a bounded retry budget.

Enqueuing

Enqueue through IBackgroundJobQueue with a BackgroundJobRequest (src/Core/Application/Jobs/):

csharp
public sealed record BackgroundJobRequest(
    string JobType,
    string PayloadJson,
    DateTimeOffset? RunAtUtc = null,   // delay/schedule; null = due now
    int MaxAttempts = 3,               // retry budget (HA)
    string? WorkspaceKey = null);

var jobId = await jobQueue.EnqueueAsync(
    new BackgroundJobRequest("voip.export", payloadJson, MaxAttempts: 5), ct);

Handling

A plugin implements IBackgroundJobHandler (src/Core/Application/Jobs/Contracts/) and exports it. The handler declares the JobType it serves and does the work:

csharp
public interface IBackgroundJobHandler
{
    string JobType { get; }
    Task ExecuteAsync(BackgroundJobExecutionContext context, CancellationToken cancellationToken = default);
}

public sealed record BackgroundJobExecutionContext(
    Guid JobId, string JobType, string PayloadJson, string? WorkspaceKey, int Attempt);

Leases, reaper, and recovery

The BackgroundJob aggregate (src/Core/Domain/Jobs/) carries the durable state (Status, AttemptCount, MaxAttempts, ScheduledAtUtc, LeaseExpiresAtUtc, LeaseToken). The processor loop (BackgroundJobProcessor) runs each tick:

  1. ReapFailExpiredExhaustedAsync(nowUtc) fails poison jobs whose lease expired and whose attempts are exhausted.
  2. ClaimTryClaimNextDueAsync(nowUtc, leaseDuration) atomically claims the next due job or reclaims one whose lease has expired (a crashed worker's job), marks it Running, sets a new LeaseExpiresAtUtc, and mints a fresh LeaseToken.
  3. Execute — invokes the handler for the job's JobType.
  4. Complete — on success marks Succeeded; on failure records the attempt with a backoff delay (or fails permanently once MaxAttempts is reached).

This is how a crashed or stalled job recovers: its lease simply expires and the next tick reclaims it. No job is lost, and none is stuck forever.

The fencing token

LeaseToken (a Guid minted on each claim) is the fencing token. It is configured as an EF concurrency token (BackgroundJobEntityTypeConfiguration), so when a slow worker whose lease was already reclaimed by another worker tries to save its result, the concurrency check fails and its write is rejected. This prevents split-brain: two workers can never both commit an outcome for the same lease.

The idempotency contract

Because delivery is at-least-once and leases can be reclaimed, a handler may run more than once for the same job. Your ExecuteAsync must therefore be idempotent: guard side effects with a natural key or an idempotency check keyed on JobId (or a payload key), so a re-run produces no duplicate effect. This is the single most important rule for job handlers — the queue guarantees the work runs, not that it runs exactly once.

Monitoring

Recent jobs are readable (read-only) at GET /api/jobs.

Choosing between them

Use the…when
event busyou must react synchronously, in-process, and possibly veto an operation before it commits
job queuethe work is asynchronous, must survive a restart, or should retry with backoff