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 rasterizing every page at ImageDpi

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

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 the template's content hash. Handlebars compilation happens once per template version, 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;    // how many reports may render at once
options.MaxRendersPerBrowser = 500;  // relaunch after this many; 0 keeps one browser 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 render holds a page, and pages cost memory. Start at 4 and raise it while watching memory rather than latency; the ceiling is usually RAM, not CPU.

The browser is replaced after MaxRendersPerBrowser renders, because Chromium's memory grows slowly across a long life — invisible in a test run, obvious in a host that has been up for a fortnight. The replacement waits for renders in flight: a busy engine carries on and recycles at the next quiet moment. That trade is deliberate, and pinned by a test — closing a browser out from under a live render would turn a memory precaution into a failed report.

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
MaxRendersPerBrowser 500 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

The two settings that most often need attention are ImageDpi (a Word or PowerPoint file is one image per page — 100 is usually plenty, 200 quadruples the bytes) and MaxConcurrentRenders. DPI is a host setting rather than a per-report one: two reports printing at different sharpness on the same deployment is a bug report waiting to happen, and PDF output is unaffected either way — its text stays vector.