On this page

Queued reports with cancellation

A customer statement may take seconds or minutes to prepare. Keep that work outside the lifetime of the submitting HTTP request. The API accepts a job, returns its identifier, and lets the caller inspect or cancel it later.

This example uses a process-local bounded Channel and an application job registry. The queue and registry expose ordinary CLR application contracts used directly by the DirectSharp workers and HTTP actions. They are not new standard-library APIs. Their complete implementation and contracts are linked in the reference.

Four separate limits

Limit Value in this example What it controls
Channel capacity 16 Jobs waiting to be acquired; admission rejects immediately when full
Worker bodies 4 Complete report operations, including scoped cleanup
Shared database Gate 2 Concurrent snapshot reads across report jobs
Shared projection Gate 8 Active CPU projection operations across report jobs

Each report also permits at most four concurrent invoice projections. Four jobs with four projections do not bypass the shared eight-permit Gate. Queue capacity is a job-count bound, not a byte-size limit. This sample stores identifiers in the queue and reads a bounded snapshot only after a worker claims a job.

Job states and ownership

Each job belongs to the authenticated subject that submitted it. The API checks access to the customer at submission, then uses the subject identity to authorize status and cancellation. The submitting HTTP request token controls its access check and admission. Once accepted, the job has its own cancellation source linked to host shutdown during execution.

Event Result
Accepted Queued, or already Running if a worker claimed it immediately
Worker claims Queued job Running; exactly one worker owns execution
Cancel before claim Cancelled; a later consumer skips it
Cancel while Running CancellationRequested becomes true; renderer receives cancellation
Work and cleanup succeed before cancellation wins publication Succeeded with a result
Cancellation wins before successful publication Cancelled without a result, even if computation ignored the token and returned
Genuine render/cleanup failure Failed with a generic public error; worker logs the original failure
Cancel after a terminal state Conflict; an existing success is not rewritten

Cancellation is a request, not thread termination or rollback. The DELETE response does not promise that a running renderer has stopped. A cancelled queued item can still occupy channel capacity until a consumer removes and skips it. A genuine failure takes precedence over a concurrent cancellation request in this application's status policy.

The registry retains at most 256 entries, evicting settled terminal entries when admission needs space. A status URL may therefore eventually return 404. There is no persistence, automatic retry, acknowledgement, duplicate-request suppression or cross-process delivery guarantee. Losing an HTTP response after admission can leave a real job whose identifier the caller did not receive; a durable service should add a deliberate idempotency policy.

The DirectSharp workers

The application support contracts provide ReportJob.TryStart, Token and FinishAsync, plus IReportRenderer.RenderAsync. FinishAsync coordinates result publication against cancellation. The reference page shows their normal CLR signatures.

The reporting source module uses one Project.ds declaration with root_namespace DirectSharp.Showcase. Place this alternative to the native reference worker in ReportWorkers.ds. It uses the existing channel consumable support and ordinary Microsoft DI:

import Global.System
import Global.System.Threading
import Global.System.Threading.Channels
import Global.Microsoft.Extensions.DependencyInjection
import Global.Microsoft.Extensions.Logging

public class ReportWorkers
    private readonly scopes as IServiceScopeFactory
    private readonly logger as ILogger <of ReportWorkers>

    public constructor(scopeFactory as IServiceScopeFactory, workerLogger as ILogger <of ReportWorkers>)
        require scopeFactory is not nothing
        require workerLogger is not nothing
        scopes = scopeFactory
        logger = workerLogger
    end constructor

    public async action RunAsync(jobs as ChannelReader <of ReportJob>)
        concurrent consume job from jobs with limit: 4
            await ProcessAsync(job)
        end consume
    end action

    private async action ProcessAsync(job as ReportJob)
        if not job.TryStart() then return

        let report as CustomerReport = nothing
        let failure as Exception = nothing
        let wasCancelled = false
        using linked = CancellationTokenSource.CreateLinkedTokenSource(Cancellation.Token, job.Token)
            try
                using scope = scopes.CreateAsyncScope()
                    let renderer = scope.ServiceProvider.GetRequiredService <of IReportRenderer>()
                    report = await renderer.RenderAsync(job.CustomerId, linked.Token)
                end using
            catch ex as OperationCanceledException
                if linked.IsCancellationRequested then
                    wasCancelled = true
                else
                    failure = ex
                end if
            catch ex as Exception
                failure = ex
            end try
            wasCancelled = wasCancelled or linked.IsCancellationRequested
        end using

        if failure is not nothing then
            logger.LogError(failure, "Report {JobId} failed", job.Id)
        end if
        await job.FinishAsync(report, failure, wasCancelled)
    end action
end class

The loop waits when the open channel is empty. Channel completion allows buffered jobs to drain; acquisition cancellation remains cancellation. Each whole iteration occupies one of the four worker slots until ProcessAsync completes. TryStart skips a queued job already cancelled by its owner.

The worker holds a scope factory, not a request-scoped repository. Every claimed job gets a fresh async-disposed scope and a scoped renderer. The scope is cleaned up before FinishAsync can publish success. A failed job is recorded and the worker continues to the next item; an unexpected channel/host failure is not disguised as an individual report result. The sample's normal logging provider is expected not to throw.

Submit, inspect and cancel

The controller is ordinary ASP.NET Core integration. The host supplies authentication with a stable NameIdentifier claim and an ICustomerAccess implementation. Missing or unauthorized job identifiers both return 404, so a caller cannot discover another subject's jobs through these endpoints.

Request Response
POST /api/report-jobs with a permitted CustomerId 202 with job snapshot and status Location
POST empty CustomerId 400
POST inaccessible customer 404
POST with no queue/tracking capacity or admission closed 429; no new job
GET /api/report-jobs/ for an owned retained job 200 with state and result only on success
DELETE /api/report-jobs/ for owned active work 202 after recording/requesting cancellation
DELETE terminal job 409
GET or DELETE missing, evicted or another subject's job 404

For example:

{ "customerId": "80b9bedf-5f73-46a0-9e33-63b6ac6b7eb0" }

The identifier must refer to a customer the authenticated subject may read. Authorization here is evaluated at admission; an application requiring permission revocation to affect already queued work must recheck at execution too.

The complete controller source includes request validation, the access-policy call and owner-filtered lookup. The equivalent DirectSharp cancellation action uses ordinary MVC and routing types:

public async action Cancel(id as Guid)
    returns IActionResult
    attributes HttpDelete("{id:guid}")

    let subjectOwner = Owner
    if subjectOwner is nothing or subjectOwner.Trim() is empty then
        return Unauthorized()
    end if

    let accepted = await queue.CancelAsync(subjectOwner, id)
    if accepted is nothing then
        return NotFound()
    end if
    if not accepted.Value then
        let error = new Dictionary <of String, String>()
        error["error"] = "Report is already terminal"
        return Conflict(error)
    end if

    let location = new RouteValueDictionary()
    location["id"] = id
    let receipt = new Dictionary <of String, Object>()
    receipt["id"] = id
    receipt["cancellationRequested"] = true
    return AcceptedAtAction("Get", location, receipt)
end action

This action belongs to a ReportJobsController with Owner and queue, plus the ordinary MVC, routing and collection imports. The linked native reference defines the same application ports and response policy. CancelAsync records the request once and awaits native cancellation callbacks; it does not wait for a running report's entire body to finish. Callback failures are observed separately from the job outcome. The application retains the latest 64 such exceptions in CancellationFailures for operational inspection; an actual host can drain that bounded diagnostic buffer into logging. No DirectSharp automatic logging behavior is implied.

Host lifetime

Register one ReportQueue, one ReportWorkers, a ReportHostedService, and a scoped IReportRenderer factory. The factory can obtain the application's scoped IInvoiceSource and borrow the shared Gates used by the renderer. The host owns those Gates and joins all workers before disposing them.

This ordinary registration helper receives the two application-specific integrations rather than inventing an identity provider or invoice database schema:

import Global.System
import Global.DirectSharp.Showcase
import Global.Microsoft.Extensions.DependencyInjection

public class ReportRegistration
    public static action AddReports(
        services as IServiceCollection,
        access as ICustomerAccess,
        createRenderer as Func <of IServiceProvider, IReportRenderer>
    )
        services.AddLogging()
        services.AddSingleton <of ICustomerAccess>(access)
        services.AddSingleton <of ReportQueue>()
        services.AddSingleton <of ReportWorkers>()
        services.AddScoped <of IReportRenderer>(createRenderer)
        services.AddHostedService <of ReportHostedService>()
    end action
end class

Use the existing controller host and configure its real authentication/authorization middleware before exposing these routes. For readable enum names in JSON, add JsonStringEnumConverter to the host's existing AddControllers JSON options. Do not treat direct controller tests as verification of authentication middleware.

ReportHostedService closes admission on shutdown, requests cancellation for every outstanding job, and joins its execution task and cancellation callbacks. Its reference implementation deliberately waits for cooperative settlement even beyond the supplied shutdown deadline; a renderer that ignores cancellation can delay shutdown. A hard process kill can lose process-local jobs. Gate disposal is not a substitute for cancelling and joining their users.

Previous: the dashboard. Next: parallel invoice processing.