Paragon

Performance and operations

Running the engine inside someone else's application for a long time. Everything here is off or conservative by default, because the failure mode of a report engine is not slowness — it is a document that is wrong, or a host that falls over on a Tuesday afternoon.


Where the time goes

One generation is four stages, and they are not remotely equal:

Stage Typical What makes it slow
Load template microseconds after the first call cached until the file changes
Bind data milliseconds a template with thousands of {{#each}} iterations
Render hundreds of milliseconds to seconds the browser: layout, fonts, charts, awaitsReadySignal
Compose milliseconds for PDF, longer for Word and PowerPoint rasterising every page at 300 dpi

The browser dominates. Optimising anything else first is wasted effort — the numbers are on every generation through IGenerationObserver (below), so measure rather than guess.

Excel loads but does not print. It goes through the same render stage as everything else — the page has to be laid out before anything can be said about what a cell looks like — and then reads the tables instead of printing them. So the browser still dominates, and the saving is the print and the rasterisation rather than the render: no PDF is produced and no page is drawn at 300 dpi, which is most of what compose costs elsewhere.

Two things are already cached and need no configuration:

  • Templates, keyed by file length and last-write time. A changed file is picked up on the next call without a restart.
  • Compiled bindings, keyed by each page's content hash. Handlebars compilation happens once per version of a page, not once per report.

The output cache

Off by default. When a report is a pure function of the JSON it is given, this skips the browser entirely for a repeat request:

services.AddParagon(options =>
{
    options.OutputCacheEntries = 100;                    // 0 disables it — the default
    options.OutputCacheTtl = TimeSpan.FromMinutes(10);   // TimeSpan.Zero keeps entries until evicted
});

The key is hash(templateHash + format + canonical JSON), so:

  • Publishing a new template version invalidates everything from the old one automatically — the hash is of the content.
  • Property order and whitespace in the payload do not matter; two clients spelling the same data differently hit the same entry.
  • Array order does matter. It is the meaning of an array, and two orders are two reports.
  • Data that is not valid JSON is not cached at all — binding raises the real error instead.
  • IParagonEngine.Refresh() clears it, because the templates it came from may have changed.

When not to turn this on

A report that is not a pure function of its input will serve a stale document and look correct doing it. That covers more templates than people expect:

  • anything printing "generated at" or today's date from the browser's clock
  • a document number or sequence the template invents
  • anything reading from an asset that changes without the template changing

If in doubt, leave it off. A cache that returns yesterday's invoice is a worse problem than a slow endpoint. The size limit is on entries, not bytes — a report is megabytes, so pick a number with that in mind: 100 entries of a 3MB PDF is 300MB.


Concurrency and the browser

options.MaxConcurrentRenders = 4;           // pages that may render at once, process-wide
options.MaxConcurrentPagesPerReport = 2;    // how many of those one report may hold
options.MaxRendersPerBrowser = 2000;        // relaunch after this many pages; 0 keeps one forever
options.RenderTimeout = TimeSpan.FromSeconds(60);

One browser serves the whole process, with a fresh context per render so nothing — cookies, storage, state — carries between reports. MaxConcurrentRenders is the real throttle: each in-flight page costs memory. Start at 4 and raise it while watching memory rather than latency; the ceiling is usually RAM, not CPU.

A report's pages render concurrently, which is what MaxConcurrentPagesPerReport bounds. Without it a five-page deck takes every slot and every other report queues behind it.

The browser is replaced after MaxRendersPerBrowser pages — counted in pages, not reports, because a page is what gets printed — since Chromium's memory grows slowly across a long life. The replacement waits for renders in flight, so a busy engine carries on and recycles at the next quiet moment.

Do not hold a request thread on a render

A render is hundreds of milliseconds at best, and RenderTimeout long at worst. Under load that starves the host's thread pool, and the first symptom is unrelated endpoints timing out.

For anything interactive, queue it: accept the request, hand back an id, generate on a worker, and let the client collect the file. System.Threading.Channels with a small number of consumers is enough — the engine is thread-safe and the semaphore already bounds what actually runs.

Always pass the caller's CancellationToken through. A client that goes away should stop costing you a browser page.


Telemetry

Register an observer before AddParagon, and every generation reports itself:

public sealed class ReportMetrics(ILogger<ReportMetrics> logger) : IGenerationObserver
{
    public void OnGenerated(ReportGenerationInfo info) =>
        logger.LogInformation(
            "{ReportCode} {Format} {PageCount}p {SizeBytes}b total={TotalMs}ms " +
            "render={RenderMs}ms cached={FromCache} correlation={CorrelationId}",
            info.ReportCode, info.Format, info.PageCount, info.OutputSizeBytes,
            info.Duration.TotalMilliseconds, info.RenderDuration.TotalMilliseconds,
            info.FromCache, info.CorrelationId);

    public void OnFailed(string reportCode, ReportOutputFormat format, Exception exception) =>
        logger.LogError(exception, "{ReportCode} {Format} failed", reportCode, format);
}

services.AddSingleton<IGenerationObserver, ReportMetrics>();
services.AddParagon(...);

ReportGenerationInfo carries Duration, BindDuration, RenderDuration, ComposeDuration, PageCount, OutputSizeBytes, TemplateVersion, TemplateHash, FromCache, CorrelationId and BlockedRequests.

Worth alerting on:

  • BlockedRequests not empty — the template reached for something outside itself. It rendered, but a font or a script did not load, so the document is probably wrong. This is the one to watch.
  • FromCache unexpectedly high — a template that should vary is not varying.
  • RenderDuration climbing over days — the browser recycle interval may be too long.

The engine also logs on its own ILogger<ParagonEngine> at Information (one line per report, with the stage breakdown), Warning (blocked requests) and Error (failures). Every line carries a scope with ReportCode and CorrelationId — taken from the ambient Activity when the host has tracing on, so a report lands in the same trace as the request that asked for it.

No logging configuration is required: in a bare ServiceCollection the engine resolves a null logger rather than failing. A host that calls AddLogging() before AddParagon() keeps its own — both registrations are TryAdd, so the first one wins.


Sizing, roughly

Starting points, not promises — measure on your own templates:

Setting Start at Raise when
MaxConcurrentRenders 4 queue depth grows and memory is comfortable
MaxConcurrentPagesPerReport 2 reports are many-paged and rarely concurrent
MaxRendersPerBrowser 2000 never; lower it if memory climbs over days
RenderTimeout 60s a legitimate template genuinely needs longer
OutputCacheEntries 0 the same report and data are asked for repeatedly

MaxConcurrentRenders is the one that most often needs attention. Rasterisation DPI is not a setting: Word and PowerPoint hold one image per page, drawn at 300, and two deployments disagreeing about how sharp their documents are is a bug report waiting to happen. PDF output is unaffected either way — its text stays vector, and Excel holds no images at all, so it is the one format the setting could never have applied to.