Report Module
The Kit's report Module: a Host adds it once, implements two ports for its report kinds, and its callers request report jobs over HTTP, then download each finished file by link.
PouryaKit.Reporting.Module is a Module the Kit ships as a package. A Host adds it once beside its own Modules. A caller requests a report job over HTTP; the Module stores the job in its own schema, generates the file in the background through the Reporting renderer, stores it in the Host's one File storage provider, and serves the job's state and a time-limited download link to its requester. A recurring retention removes expired jobs and their files.
The report kinds are the Host's. It implements two ports: IReportRequestAuthorizer decides who may request a kind with which parameters, and IReportLayouts lays the kind out from what the application holds.
One connection name, fixed
The Module stores over one fixed connection string name and cannot be told another, since a Composition Root takes no parameters. Give the Host's database that name, and keep the message store on the same database (Composing it). The file is rendered in memory inside the attempt's Unit of Work, so keep a kind to what fits there.
When to use it
- An Actor requests a report that takes a while to build, and downloads it when it is ready.
- You add report kinds to a Host by implementing the two ports.
- You need to know what a stale claim, a late settlement, another Actor or tenant, or the retention does to a job.
- You test report jobs against a storage container.
How it works
Compose it in the Host
Add ReportModule with the Host's other Modules, register one file storage provider and the two ports, and run deliveries with UsePouryaKitWolverine. The Module's migrations ship in the package.
Authorize a request, then lay it out
MayRequestAsync runs in the request's authorization step, before anything is stored, so a refused request stores and schedules nothing (The Host's ports). LayOutAsync runs later in the background, as a continuation of the request with the requester's Actor and tenant, and may run more than once for one job.
Follow a job to its file
A request is accepted with the job's location, and reading the job answers its state and, once it is completed, a presigned download link. Only the requester reads a job, within its tenant, unless the Host grants an Actor the tenant's jobs (The endpoints).
In the Proving Sample
The Host's one report kind, a month's sales, implements both ports in one class: a person of a tenant may request it for a month, laid out as one table.
/// <summary>
/// The Host's one report kind, <see cref="Kind"/>: a month's sales, which a person of a tenant may request for a month
/// given as <c>yyyy-MM</c>, laid out as one table. A real application reads the lines from its Modules, asking each
/// through its Contracts with the context's <c>Continue()</c>; the sample's are fixed.
/// </summary>
public sealed class SalesReports : IReportRequestAuthorizer, IReportLayouts
{
public const string Kind = "sales";
public const string Month = "month";
public Task<bool> MayRequestAsync(
OperationContext context,
ReportRequest request,
CancellationToken cancellationToken
)
{
ArgumentNullException.ThrowIfNull(context);
ArgumentNullException.ThrowIfNull(request);
return Task.FromResult(
context.Actor.Kind == ActorKind.Person
&& context.Tenant is not null
&& request.Kind == Kind
&& MonthOf(request) is not null
);
}
public Task<Result<ReportLayout>> LayOutAsync(
OperationContext context,
ReportRequest request,
CancellationToken cancellationToken
)
{
ArgumentNullException.ThrowIfNull(context);
ArgumentNullException.ThrowIfNull(request);
var month =
MonthOf(request)
?? throw new InvalidOperationException(
$"The {Kind} report was requested without a month."
);
Result<ReportLayout> layout = new ReportLayout(
"Monthly sales",
new ReportTable(
[
new("Product"),
new("Quantity", ReportAlignment.Right),
new("Amount", ReportAlignment.Right),
],
[
["Tea", "12", "30.00"],
["Coffee", "7", "24.50"],
]
)
)
{
Introduction =
[
new ReportHeading(month.ToString("MMMM yyyy", CultureInfo.InvariantCulture)),
new ReportParagraph($"Every order settled this month, for {context.Tenant}."),
],
PageFooter = "Confidential",
Branding = new() { Accent = new(0x1F, 0x4E, 0x79) },
};
return Task.FromResult(layout);
}
// The one parameter the kind takes, exactly.
private static DateOnly? MonthOf(ReportRequest request) =>
request.Parameters.Count == 1
&& request.Parameters.TryGetValue(Month, out var month)
&& DateOnly.TryParseExact(
month,
"yyyy-MM",
CultureInfo.InvariantCulture,
DateTimeStyles.None,
out var parsed
)
? parsed
: null;
}Guarantees and failures
| You want to know | Read |
|---|---|
| What the Host registers, and the Module's settings | Composing it |
| What each port receives, and when it runs | The Host's ports |
| What each endpoint answers, and who may read a job | The endpoints |
| A job's states, from its request to its removal | A job's life |
| Fenced claims, links that never outlive their job, and retention | What the Kit guarantees |
| What the Module does not offer | Limits |
Next steps
Reporting
The layouts and formats the Module renders.
File storage
The provider the Module stores its files in.
Security
The permission that lets an Actor read a tenant's jobs.
Reference
PouryaKit.Reporting.Module: the first Module the Kit ships as a package. A Host adds it once; it stores report jobs in its own schema, generates each in the background through the renderer (reporting.md) into the Host's one file storage provider (storage.md), serves each job's state and download link to its requester, and removes expired jobs and their files. The report kinds and their layouts are the Host's, through two ports it implements. Namespace PouryaKit.Reporting.Module, one for the whole package; every type is internal but the Composition Root ReportModule, the two ports and the ReportRequest they receive. It references Modularity, Persistence.Postgres, Persistence.Dapper, Messaging.Wolverine.EFCore, Validation, Security, Web, Reporting and Storage, and no storage provider.
Composing it
The Proving Sample's Host, ModularMonolithHost.CreateBuilder:
using PouryaKit.Reporting.Module;
using PouryaKit.Storage.S3;
builder.AddPouryaKitModules(modules =>
{
modules.Add<CatalogModule>();
modules.Add<ReportModule>();
});
builder.Services.Configure<PouryaKitS3StorageOptions>(builder.Configuration.GetSection("PouryaKit:Storage:S3"));
builder.Services.AddPouryaKitS3Storage();
builder.Services.AddSingleton<IReportRequestAuthorizer, SalesReports>();
builder.Services.AddSingleton<IReportLayouts, SalesReports>();
builder.UsePouryaKitWolverine(connectionName: "app");- Its database. It stores over the connection string
app(ReportModule.ConnectionName), in the schemareports, and reads with Dapper over the same name. A Composition Root receives no configuration, so the name is the Module's: give the Host's database that name, and keep the message store on the same database, since its delayed and recurring commands commit through its Outbox and the message store check fails the build otherwise (messaging.md). Its migrations are in the package:HostFixtureapplies them in tests, and a deployment applies them as it applies every Module's (persistence.md). Its first migration creates itspouryakit_receiptstable too. - What the Host registers. An
IReportRequestAuthorizer, anIReportLayouts, one file storage provider and anIActorPermissions(security.md). The Host's container is validated when it is built, so a Host that registers no service of one of them fails then, naming the service. The Module registers the renderer itself,TryAddSingleton<IReportRenderer, ReportRenderer>(). - Its settings. The Module reads them from the Host's configuration section
PouryaKit:Reports, as a Module reads its own settings (modules.md), into an options type of its own, validated when the Host starts, which fails unless each is longer than zero,Retention,CleanupIntervalandClaimTimeoutat most 36500 days, andLinkLifetimeat most seven days:
| Key | Default | What it sets |
|---|---|---|
Retention | 7 days | How long after its request a job and its file are kept. |
CleanupInterval | 1 hour | The cadence of the retention, the Module's recurring command reports.retention. |
ClaimTimeout | 10 minutes | How long a generation attempt holds its claim before the one takeover may take the job; set it longer than the slowest report takes to lay out, render and store. |
LinkLifetime | 15 minutes | How long a download link works, at most seven days, the longest link S3 and Azure Blob sign. A link never outlives its job: one signed less than LinkLifetime before the job's expiry works only until that expiry. |
The Host's ports
The Proving Sample's SalesReports, one class registered for both ports, shortened:
using System.Globalization;
using PouryaKit.Cqrs;
using PouryaKit.Domain;
using PouryaKit.Reporting;
using PouryaKit.Reporting.Module;
public sealed class SalesReports : IReportRequestAuthorizer, IReportLayouts
{
public const string Kind = "sales";
public Task<bool> MayRequestAsync(OperationContext context, ReportRequest request, CancellationToken cancellationToken) =>
Task.FromResult(context.Actor.Kind == ActorKind.Person && context.Tenant is not null && request.Kind == Kind && MonthOf(request) is not null);
public Task<Result<ReportLayout>> LayOutAsync(OperationContext context, ReportRequest request, CancellationToken cancellationToken) =>
Task.FromResult<Result<ReportLayout>>(
new ReportLayout("Monthly sales", new ReportTable([new("Product"), new("Amount", ReportAlignment.Right)], [["Tea", "30.00"]]))
);
private static DateOnly? MonthOf(ReportRequest request) =>
request.Parameters.Count == 1
&& request.Parameters.TryGetValue("month", out var month)
&& DateOnly.TryParseExact(month, "yyyy-MM", CultureInfo.InvariantCulture, DateTimeStyles.None, out var parsed)
? parsed
: null;
}ReportRequest(Kind, Format, Parameters): the kind the Host defines, theReportFormatto render to, and the kind's parameters by name, as text the kind parses.IReportRequestAuthorizer.MayRequestAsync(context, request, ct)decides whether the Operation's Actor and tenant may request this kind with these parameters. It runs in the request's authorization step, before validation, so it receives the request as the caller gave it; false refuses it withReports.RequestDenied(403), and nothing is stored or scheduled.IReportLayouts.LayOutAsync(context, request, ct)lays the kind out (reporting.md). It runs in the background, under a continuation of the request, with the requester's Actor, tenant and correlation, so a layout that asks another Module continues the context withContinue()(modules.md). It may run more than once for one job: for each generation attempt, and again when an attempt is retried. A failed Result fails the job with its first Error's code.
The endpoints
Under the route group /reports, authenticated as every Module's endpoints are (web.md):
POST /reportswith{ "kind": "sales", "format": "Csv", "parameters": { "month": "2026-10" } }answers202 Acceptedwith{ "id": … }andLocation: /reports/{id}.formatis required,Pdf,CsvorSpreadsheet: a body without one, or with another name, fails to bind and answers 400 before the request reaches the Host's port. Once the port allowed it, the request is validated: a kind of 1 to 100 characters, a defined format, at most 20 parameters with names up to 100 and values up to 1000 characters, each failure a 400.GET /reports/{jobId}answers{ id, kind, format, state, downloadUrl, failure, expiresAt }:stateisRequested,Generating,Completed,FailedorExpired;downloadUrlis a presigned link to the file once the job isCompleted(IFileStorage.GetPresignedUrlAsyncforLinkLifetime, or untilexpiresAtwhen that comes sooner);failureis the first Error's code once it isFailed. A job read at or past itsexpiresAtanswersExpired, with nodownloadUrl, even before the retention stores that state.- Only the requester reads a job, within the job's tenant, and so does an Actor whose
IActorPermissionsgrantsReportModule.ReadTenantJobsPermission(reports.jobs.read-tenant), for every job of its own tenant and of no other: a tenant-wide read is a permission the Host grants. Anyone else, another Actor of the tenant or the requester under another tenant, getsReports.JobNotFound(404), as for a job that does not exist, and no link is signed for them. - There is no endpoint that lists jobs.
A job's life
- Requested.
POST /reportsstores the job, with its requester, its tenant and an expiry of the request time plusRetention, and schedules its first claim, a delayed command due at once (messaging.md), in the same commit. - Claimed. The claim, a continuation of the request, gives the job attempt 1, commits, and schedules the attempt's generation, due at once, and the one takeover, due when the claim's
ClaimTimeouthas passed. The claim commits on its own, before anything renders, so the takeover sees it. - Generated. The attempt, if it still holds the claim, asks the Host's
IReportLayouts, renders the layout, stores the file under its own key,reports/{job id}/{attempt}, and settles:Completed, publishing that key, orFailedwhen the layout or the renderer refused. A failure of the store is retried, as a delivery's failure is. An attempt that finds, when it starts, that it no longer holds the claim deletes its own key, unless the job publishes it: an attempt taken over, expired or removed while it rendered stored its file there, its settlement was refused, and its retry removes that file. - Taken over, once. When the takeover comes due and the first attempt still holds its claim, older than
ClaimTimeout, it claims the job for attempt 2, which generates as above. A takeover schedules no further takeover; when the first attempt settled first, the takeover changes nothing. - Expired, then removed. The retention runs every
CleanupIntervalas the system Actorreports.retention(ReportModule.RetentionActor), across every tenant. It expires each job past its expiry, except one whose attempt holds a claim younger thanClaimTimeout; an expired job serves nothing. A later occurrence deletes every file the job's attempts may have stored, then the job, which then answers not found. Each step takes up to 100 jobs per occurrence, oldest expiry first.
What the Kit guarantees
- A refused request leaves nothing. The Host's port decides before any job is stored; a refusal stores no job and schedules no claim.
- Fenced claims and settlements. Every claim, settlement and expiry is saved under the job's version, so of two that read one version the second to commit is refused, rolls back and, retried, finds the job changed. A stale claim is taken over once. An attempt taken over while it renders stores its file under its own key, which the job never serves, and its settlement is refused; the job serves the winning attempt's file, and the late attempt's retry deletes its own.
- Read by its requester alone. A job is read within its tenant, by its requester or an Actor granted the tenant's jobs; a link is signed only after both checks passed.
- A link never outlives its job. A link is signed for
LinkLifetimeor until the job's expiry, whichever comes sooner, and a job read at or past its expiry gets none, so the retention never deletes a file a link still promises. - Retention never deletes a served file. It removes the jobs past their expiry and their files in every tenant, and keeps a job not yet expired with its file. It deletes a job's files only in an occurrence after the one that committed the job's expiry, so a settlement that commits first refuses the expiry by the job's version and keeps its file.
- A Module like any other. The structure tests judge it with the sample's Modules: no save or commit in its code, no transport type, no public type but the ones above, one namespace (testing.md).
Limits
- One connection string name,
app, which the Host cannot change:IModuleDefinitiontakes no parameters. - A setting out of range fails the start with its one failure reported twice in the
AggregateExceptionthe start throws: the settings are validated themselves, and again when the retention's cadence readsCleanupIntervalfrom them. The failure names the bounds. - The file is rendered in memory, as one byte array, inside the attempt's Unit of Work, whose transaction stays open while the layout, the renderer and the store run: keep a kind to what fits in memory and within
ClaimTimeout. - A job taken over whose second attempt also stalls stays
Generatinguntil its expiry; the retention then removes it. - A late attempt's file goes when its delivery is retried; a delivery whose every retry fails to delete it is set aside as a dead letter of
reports.render, and the file stays until that dead letter is replayed. - A download link grants anyone who holds it read access until it expires (storage.md).
- No report-list endpoint, no cancelling, no per-tenant retention and no notice when a report is ready: a client polls the job endpoint.
Testing
The Kit's tests run the Module over HTTP in the Proving Sample's Host, on a database of its own, with its files in a RustFS bucket (ReportFixture, a class fixture beside AppFixture). The fixture replaces the Host's ports with TestReports, which hands the sample's kind to SalesReports and holds the first layout of its held kind until released, and sets a ClaimTimeout of 2 seconds and a retention every second. A test follows a job by polling GET /reports/{jobId}, reads its file through the link with a plain HttpClient, and reads the claims the Module stored from the message store's envelopes. RecordingFileStorage stands in front of the S3 provider, as the Host's one registration, and records every link it signs with its lifetime. ReportFixture.HoldDeliveriesAsync holds every delivery of the Module, the retention's included, by locking its Receipt table in a transaction of the test's, since a delivery records its Receipt before its command runs; a read records none, so a job past its expiry is read before the retention stores it. ReportFixture.Compose composes a Host as the fixture's, with settings of the test's, for the start refusals.
Reporting
Lay out a Module's report once and render it to a PDF, a CSV file or a spreadsheet in memory, with a logo, an accent colour and an embedded font.
AI
The Host's one chat client for OpenAI, Azure OpenAI or Ollama, a Module's commands and queries as Gateway tools that run as Operations, agents and workflows over that client, and semantic search over pgvector.