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.