Generate multiple reports and deliver a single PDF or ZIP
Overview
Sometimes a business action must generate several reports, but the end user should receive only one final result:
- one merged PDF containing all generated reports,
- or one ZIP containing every generated PDF.
This requirement appears frequently in mass printing scenarios such as invoice batches, document packs, or customer mailings.
With Neos, the recommended approach is:
- trigger each report generation asynchronously with
IReportGenerator, - process each completion in a callback,
- persist the batch progression in a shared store,
- finalize only when every expected report has completed,
- send a single final notification to the user.
This article explains the pattern in detail and points to a working example in TechnicalDemos.
Important
Do not aggregate the batch only in memory. Report callbacks can run on a different backend instance than the one that started the batch.
When to use this pattern
Use this approach when:
- the user launches one action but expects several generated reports,
- each report can be generated independently,
- the final deliverable must be a single user-facing artifact,
- the application may run with several backend replicas,
- you want the completion notification to be available through the notification center.
Typical examples are:
- print all invoices for a selection,
- generate one customer pack containing several report layouts,
- archive a whole set of PDFs as a ZIP,
- prepare one asynchronous export-like result from multiple report templates.
Why the naive approach is not reliable
The most tempting implementation is:
- start
Nreport generations, - store completed results in a static dictionary or service field,
- when the counter reaches
N, merge or zip the files.
This is not reliable in Neos.
The callback passed to IReportGenerator.RequestAsync<T>(...) does not necessarily run:
- in the same HTTP request,
- on the same thread,
- or on the same backend replica.
The official report generation API already warns about this execution model. User, tenant, and SignalR context are restored, but in-memory aggregation is not shared across replicas.
If you keep the batch state only in memory, you can lose progress, finalize too early, or never finalize at all.
Recommended architecture
The robust pattern is based on four responsibilities:
- a server method starts the batch and creates a persisted batch state,
- one callback processor handles each generated report,
- a shared state store tracks global progress,
- a temporary file storage keeps intermediate PDFs until finalization.
sequenceDiagram
participant UI as UI action
participant SM as Server method
participant RG as IReportGenerator
participant RS as Reporting service
participant CB as Callback processor
participant SS as State store
participant FS as Temporary file storage
participant UN as IUserNotification
UI->>SM: Launch batch generation
SM->>SS: Save initial batch state
loop For each expected report
SM->>RG: RequestAsync(..., callback)
RG->>RS: Generate report asynchronously
RS-->>CB: Callback with ReportGenerationResponse
CB->>FS: Save generated PDF content
CB->>SS: Persist success or failure
CB->>SS: Try to mark finalization started
alt All reports completed and finalization acquired
CB->>FS: Merge PDFs or create ZIP
CB->>UN: Send one final notification
end
end
Core API to use
The entry point is IReportGenerator.RequestAsync<T>(...).
Example:
await _reportGenerator.RequestAsync<ReportsBatchProcessor>(
requestArguments,
(ReportsBatchProcessor processor, ReportGenerationResponse response)
=> processor.OnReportGeneratedAsync(batchId, reportOrder, response));
In this form, the framework resolves the callback target through dependency injection, then supplies the generated ReportGenerationResponse when the report is completed.
The callback processor type must therefore be registered in dependency injection.
In the TechnicalDemos sample, ReportsBatchProcessor is registered as Transient.
Do not register it as Singleton if it depends on scoped services.
This gives you:
- one asynchronous request per report,
- one callback invocation per completed report,
- restored user and tenant context,
- compatibility with the standard report generation pipeline.
If you are not familiar with this API yet, read Generate a report from code first.
Step 1: create a batch server method
Create a server method dedicated to the mass action. Its role is not to build the final PDF or ZIP immediately. Its role is to:
- determine the list of reports to generate,
- create a unique batch identifier,
- save an initial persisted state,
- launch one asynchronous report generation per expected report,
- return immediately to the user.
The batch identifier is the correlation key shared by every callback of the same mass action.
The initial persisted state should contain at least:
- the expected number of reports,
- whether the final output is a merged PDF or a ZIP,
- a finalization flag,
- the successful intermediate file identifiers,
- the display file names,
- the failures already observed.
Example state shape:
public sealed record ReportsBatchState(
int ExpectedCount,
bool AsZip,
bool FinalizationStarted,
IReadOnlyDictionary<int, Guid> SuccessfulFileIdentifiersByOrder,
IReadOnlyDictionary<int, string> FileNamesByOrder,
IReadOnlyDictionary<int, string> FailureMessagesByOrder);
The exact shape can vary, but the state must be sufficient to answer two questions safely:
- is the batch complete,
- has another replica already started finalization.
Step 2: pass correlation data to the callback
Each request should pass enough information to the callback to identify:
- which batch it belongs to,
- which expected report it represents.
In the TechnicalDemos example, the callback receives:
batchId,reportOrder,response.
This is enough to update the correct slot in the persisted batch state.
Step 3: persist each callback result immediately
When the callback receives a ReportGenerationResponse, do not wait for all reports before persisting anything.
Instead:
- inspect whether the report generation succeeded,
- if it succeeded, save the generated PDF content to temporary storage,
- if it failed, store the failure message,
- update the batch state immediately.
Persisting intermediate files outside the state store is important:
- PDFs can be large,
- the state store should keep lightweight coordination data,
- the finalizer only needs file identifiers and names to rebuild the final artifact.
Why ITemporaryFileStorage is a good fit
ITemporaryFileStorage is appropriate for intermediate artifacts because:
- it avoids placing binary payloads in the coordination state,
- it is already designed for temporary download-oriented files,
- the finalizer can reopen each generated PDF later,
- cleanup remains simple once the batch is finalized.
The callback can therefore transform a generated report into a persisted temporary artifact as soon as it arrives.
Step 4: protect the state update with optimistic concurrency
Parallel callbacks may update the same batch state concurrently.
The callback must therefore:
- read the current batch state with its
ETag, - compute the updated state,
- save it with optimistic concurrency,
- retry if another callback updated the same state first.
This retry loop is what makes the aggregation safe under parallel report completions.
When saving the state, prefer:
StateStoreConsistency.Strong,StateStoreConcurrency.FirstWrite.
This avoids the classic lost-update scenario where two callbacks read the same old state and the last writer silently overwrites the other progress.
Step 5: acquire finalization exactly once
Once a callback has persisted its own result, it can check whether the batch is now complete.
Completion alone is not enough. Two callbacks may both observe that the batch is complete. You also need a distributed lock-like marker.
The simplest pattern is:
- detect
IsComplete, - try to save a new state with
FinalizationStarted = true, - only the callback that wins this write performs the final merge or ZIP creation,
- every other callback stops there.
This ensures that the final artifact and the final notification are produced only once.
Step 6: build the final artifact
Final merged PDF
For a merged PDF:
- reopen all successful intermediate PDFs in the expected order,
- merge them into one output stream,
- save the merged result to temporary storage,
- create a final
ReportGenerationResponseor equivalent download descriptor, - send one report completion notification.
The TechnicalDemos sample uses iText with PdfMerger for this step.
Final ZIP
For a ZIP:
- reopen each successful intermediate PDF,
- create one zip entry per report,
- save the archive to temporary storage,
- publish one export-completed notification.
The ZIP path is conceptually closer to an export than to a single report preview, which is why using the export notification family is often the most natural choice.
Step 7: notify the user once
Do not send one notification per generated report if the business requirement is to expose one final result.
Instead:
- send a single notification when the merged PDF is ready,
- or send a single notification when the ZIP is ready,
- or send one error notification if the batch cannot produce a usable final result.
Using IUserNotification is the recommended abstraction because it integrates with the cluster notification system.
Typical methods are:
SendReportGenerationSucceededNotificationAsync(...),SendReportGenerationFailedNotificationAsync(...),SendExportCompletedNotificationAsync(...),SendExportCompletionErrorNotificationAsync(...).
If the NeosNotificationCenter module is installed, these notifications are also persisted in the notification center, not only pushed as transient toasts. This means the batch pattern remains compatible with the notification center without any special case in your business code.
For general notification concepts, see Notify client from the server.
Failure strategy
You must define the business rule for partial failures.
The most common strategies are:
- fail the whole batch if any report fails,
- produce the final artifact only from successful reports,
- produce the final artifact and include a summary of skipped reports.
The TechnicalDemos sample uses the first strategy: if one report fails, the batch ends with a single failure notification and no final PDF or ZIP is produced. The sample keeps the logic simple and is intended as an educational example. In a business application, choose the rule explicitly and document it.
At minimum, the persisted batch state should record failures distinctly from successes so the finalizer can make a deterministic decision.
Cleanup strategy
Once finalization is complete, delete the intermediate temporary files when possible.
You should also set a TTL on the batch state. This protects the system if:
- a report generation never completes,
- a callback crashes before cleanup,
- a batch is abandoned.
The TechnicalDemos sample uses a state TTL for this reason.
TechnicalDemos example
The complete example is available in the TechnicalDemos cluster, in the Reports module.
Functional entry point:
- menu:
Reports>Customers - action identifiers:
ReportsAllInOnePdfandReportsAllInOneZip
Relevant source files:
demos/technicaldemos/modules/Reports/businessAssembly/Application/Methods/DownloadReportsBatch.csdemos/technicaldemos/modules/Reports/businessAssembly/Application/ReportsBatchProcessor.csdemos/technicaldemos/modules/Reports/businessAssembly/Application/ReportsBatchState.csdemos/technicaldemos/modules/Reports/businessAssembly/Application/Startup.csdemos/technicaldemos/modules/Reports/metadata/UIViewActions/ReportsCustomerUI.ymldemos/technicaldemos/modules/Reports/metadata/UIViewActions/fr/ReportsCustomerUI.yml
What this example demonstrates:
- two UI actions, each triggering several report generations,
- callback orchestration with
IReportGenerator, - persisted multi-replica-safe aggregation with
IStateStore, - intermediate storage with
ITemporaryFileStorage, - final PDF merge or ZIP creation,
- one final notification compatible with the notification center.
Design checklist
Before implementing this pattern in a real module, verify the following points:
- the server method returns immediately after scheduling report generations,
- each callback persists its own result before attempting finalization,
- the batch state is stored in a shared persistent mechanism,
- optimistic concurrency is used on state updates,
- finalization is acquired exactly once,
- intermediate files are stored outside the state object,
- the final notification is sent only once,
- cleanup and TTL are defined,
- partial failure behavior is explicitly decided.
What this pattern does not solve by itself
This pattern solves multi-replica coordination for normal parallel execution.
It does not automatically solve every operational recovery scenario, for example:
- a crash occurring just after the finalizer marks
FinalizationStarted = true, - a need for manual retry or restart of an unfinished batch,
- long-term audit or business traceability requirements.
If your business scenario requires those guarantees, add a dedicated recovery workflow on top of this base pattern.