Distributed Deployments

Serve one document from several nodes

When several nodes serve the same documents, the node that renders a document is not necessarily the node that answers the browser's page requests. This release splits that job into two explicit halves — a write side that publishes a document's artifacts into shared storage, and a read side that serves them from any node.

If your application currently calls UseDoconutWebFarm(...), this page is your replacement. Neither UseDoconutWebFarm nor WebFarmOptions exists in this release.

Your topology does not change. Point the shared root at the same UNC path you use today and every node keeps working the way it does now. What changes is that publishing is an operation you call, instead of a side effect of rendering.

What replaced the web-farm middleware

Previously one middleware did everything: it served pre-rendered images out of a shared folder and polled while another node was still writing them. The wait was a blind budget — "block up to three minutes hoping the page appears".

Now the same job is two calls you can see and reason about:

text
node A            open request
                      -> PublishAsync(...)   writes pages, document info,
                                             search index and metadata
                                             into  RootPath/{token}/
                      -> returns the token

node B            ?token=...&page=7
                      -> UseDoconutCloud<FileShareCloudHandler>
                      -> reads RootPath/{token}/ and serves the page

The shared configuration both halves need

The publisher writes with a FileShareConfig and the read handler serves with one. If RootPath or UsePerTokenSubfolders differ between them, the other node answers 404. Build the config in exactly one place and register it once, so both halves read the same object:

csharp
var rootPath = builder.Configuration["Doconut:SharedRoot"]!;   // e.g. \\fileserver\doconut
builder.Services.AddSingleton(SharedStore.Config(rootPath, asyncPublish: false));

internal static class SharedStore
{
    public static FileShareConfig Config(string rootPath, bool asyncPublish) => new()
    {
        Location              = CloudLocation.FileShare,
        RootPath              = rootPath,
        UsePerTokenSubfolders = true,
        ReadReadyPollMs       = asyncPublish ? 250 : 0,
    };
}

Pass asyncPublish: true when you turn on background publishing.

FileShareConfig lives in Doconut.Clouds, together with AzureConfig, AmazonConfig, GoogleCloudConfig, DropBoxConfig, RedisConfig, CDNConfig, and FTPConfig. In the previous library these were in Doconut.Configs.Cloud; the class names did not change.

PropertyPurpose
LocationCloudLocation.FileShare for a UNC/local share
RootPathThe shared folder every node can reach
UsePerTokenSubfoldersKeep each document's artifacts in its own {token} folder (default true)
ReadReadyPollMsHow long the read side waits for a page that a background publish is still writing; 0 (the default) checks once. It has no effect on documents published synchronously

Write side — publish the document

Inject DistributedDocumentPublisher and publish instead of opening a local session. One call renders and writes the complete artifact set — pages, document info, search index, and metadata — and returns when it is done:

csharp
app.MapPost("/api/open", async (string fileName, FileShareConfig shareConfig,
    DistributedDocumentPublisher publisher, CancellationToken ct) =>
{
    var path            = Path.Combine("files", Path.GetFileName(fileName));
    var config          = new PdfConfig { AllowSearch = true };
    var documentOptions = new DocOptions { TimeOut = 60 };
    var publishOptions  = new DistributedDocumentPublishOptions { CleanUpStagingDirectory = true };

    DistributedDocumentPublishResult result = await publisher.PublishAsync(
        path, shareConfig, config, documentOptions, publishOptions, ct);

    return Results.Text(result.Token);
});

DistributedDocumentPublishResult carries Token, ArtifactPath, TotalPages, and Format. The token goes to the browser exactly as a normal viewer token does. In a farm, the endpoint also returns a signed access ticket — see Security across nodes.

This single call replaces the previous pair — opening the document into a local session and then dumping images onto the share with ExportToPng(sharedPath). ExportToPng and ExportToCloud have no equivalent here.

Read side — serve from the share

Mount the FileShare handler on the same branch the client already calls, behind the ticket check:

csharp
var app = builder.Build();

app.UseDoconutResources();
app.MapWhen(
    ctx => ctx.Request.Path.Value?.EndsWith("DocImage.axd", StringComparison.OrdinalIgnoreCase) == true,
    branch =>
    {
        branch.UseDoconutDistributedDocumentSecurity();   // signed tickets — see below
        branch.UseDoconutCloud<FileShareCloudHandler>(options =>
        {
            options.Location          = CloudLocation.FileShare;
            options.CloudUploadConfig = app.Services.GetRequiredService<FileShareConfig>();
        }, "/");
    });

UseDoconutCloud is generic in this release: the provider is the type argument rather than a Location value switched on inside the middleware. Handlers are named <Provider>CloudHandler — FileShareCloudHandler, AzureCloudHandler, AmazonS3CloudHandler, GoogleCloudHandler, DropBoxCloudHandler, RedisCloudHandler, FTPCloudHandler, and CDNCloudHandler. With the path prefix "/", the handler answers every request that reaches its branch.

The CDN handler is read-only — it serves what is already on the CDN; it does not upload.

Optional: publish in the background

By default PublishAsync renders the whole document before it returns. To return as soon as the token is minted and let a hosted worker render into the same folder, register the opt-in queue, and build the shared config with asyncPublish: true:

csharp
builder.Services.AddDoconut(options => { /* … */ });
builder.Services.AddDoconutDistributedAsyncPublish();

Without this registration nothing is registered and publishing stays synchronous, exactly as before.

Then call EnqueueAsync instead of PublishAsync. (DistributedDocumentPublishOptions.Async is only a flag your own code can read to choose between the two paths; the publisher ignores it.) The queue is bounded: when it is saturated, EnqueueAsync throws DistributedPublishQueueFullException. Answer it with a 503 and a Retry-After header so clients back off rather than stall:

csharp
app.MapPost("/api/open", async (string fileName, FileShareConfig shareConfig,
    IDistributedPublishQueue publishQueue, HttpResponse response, CancellationToken ct) =>
{
    var path            = Path.Combine("files", Path.GetFileName(fileName));
    var config          = new PdfConfig { AllowSearch = true };
    var documentOptions = new DocOptions { TimeOut = 60 };
    var publishOptions  = new DistributedDocumentPublishOptions { FirstPagePriority = true };

    try
    {
        var token = await publishQueue.EnqueueAsync(
            path, shareConfig, config, documentOptions, publishOptions, ct);
        return Results.Text(token);
    }
    catch (DistributedPublishQueueFullException)
    {
        response.Headers["Retry-After"] = "5";
        return Results.StatusCode(503);
    }
});

Pages that are not written yet: HTTP 202

With background publishing, a page request can arrive before the worker has written that page. The read side waits up to ReadReadyPollMs and then answers HTTP 202 Accepted instead of blocking the request.

FirstPagePriority = true shortens the wait for the first view: page 1 is written into the document's folder first, so the document opens while later pages are still rendering. It applies only to background publishing into per-token folders (UsePerTokenSubfolders = true); the synchronous PublishAsync ignores it, because its caller waits for the whole document anyway.

That moves the waiting to the client, where it is bounded and visible. Enable retry on the widget:

javascript
const objViewer = $('#div_ctlDoc').docViewer({
    BasePath:   '/',
    ResPath:    'doconut-res/images',
    retryOn409: true      // retries 202 (and legacy 409) readiness responses
});

A client that does not retry shows broken tiles for pages that are still rendering. The retry budget and its defaults (retryInitialDelayMs, retryBackoffFactor, retryMaxDelayMs, retryMaxAttempts, retryMaxTotalMs) are documented in ViewerConfig.

This pair — background publishing with FirstPagePriority, plus the read-side poll — is what replaces the previous PageWaitTimeSeconds and StartWaitFromPage settings. Neither exists any more.

Security across nodes

Session-bound tokens assume the serving node saw the opening request. In a farm it did not, so the per-session check would reject legitimate requests; a farm therefore sets UnsafeMode = true.

UnsafeMode = true is not the security model — it is the removal of one that no longer applies. Replace it with signed access tickets, which every node can verify independently. Four pieces work together.

1. Register ticket security on every node. Tickets are protected with ASP.NET Core Data Protection, so every node must use the same key ring and application name; a node with different keys cannot read a ticket another node issued.

csharp
using Microsoft.AspNetCore.DataProtection;

builder.Services.AddDataProtection()
    .PersistKeysToFileSystem(new DirectoryInfo(@"\\fileserver\doconut-keys"))   // shared by every node
    .SetApplicationName("doconut-farm");

builder.Services.AddDoconut(options => options.UnsafeMode = true);
builder.Services.AddDoconutDistributedDocumentSecurity(options =>
{
    options.TicketLifetimeMinutes = 60;   // the default
});

2. Check tickets on the read side. UseDoconutDistributedDocumentSecurity() goes on the read branch before UseDoconutCloud, as in the read-side sample.

3. Issue a ticket when the document is opened. The ticket names the token, the browser (through a cookie the read side compares), and the signed-in user, if any:

csharp
using System.Security.Claims;
using Microsoft.Extensions.Options;

app.MapPost("/api/open", async (string fileName, HttpContext http, FileShareConfig shareConfig,
    DistributedDocumentPublisher publisher, IDocumentAccessTicketProtector protector,
    IOptions<DistributedDocumentSecurityOptions> security, CancellationToken ct) =>
{
    var path   = Path.Combine("files", Path.GetFileName(fileName));
    var result = await publisher.PublishAsync(path, shareConfig, ct: ct);

    // Bind the ticket to this browser through the cookie the read side checks.
    var cookieName = security.Value.BrowserCookieName;   // "doconut-client" by default
    if (!http.Request.Cookies.TryGetValue(cookieName, out var browserId) || string.IsNullOrEmpty(browserId))
    {
        browserId = Guid.NewGuid().ToString("N");
        http.Response.Cookies.Append(cookieName, browserId, new CookieOptions
        {
            HttpOnly = true,
            Secure   = http.Request.IsHttps,
            SameSite = SameSiteMode.Lax
        });
    }

    var access = protector.Protect(new DocumentAccessTicket
    {
        Token      = result.Token,
        BrowserId  = browserId,
        UserId     = http.User.FindFirstValue(ClaimTypes.NameIdentifier) ?? "",
        ExpiresUtc = DateTimeOffset.UtcNow.AddMinutes(security.Value.TicketLifetimeMinutes),
        Nonce      = Guid.NewGuid().ToString("N")
    });

    return Results.Json(new { token = result.Token, access });
});

4. Pass both to the widget. The viewer sends the ticket as access with every request:

javascript
const resp = await fetch('/api/open?fileName=Sample.pdf', { method: 'POST' });
const { token, access } = await resp.json();
objViewer.View(token, access);

The read side refuses a request whose ticket is missing, invalid, issued for another token, expired, bound to another browser cookie, or issued to another signed-in user. It answers 403 — with the UnauthorizedMessage error image for page and thumbnail requests, as plain text otherwise. Test with a real browser or a client that keeps cookies.

Never carry UnsafeMode = true back into a single-node deployment, and never treat it as a shortcut for a failing session check on one node — there the correct fix is AddSession() / UseSession(). See Sessions & Security.

Closing a published document

CloseDocument(token) on the injected Viewer disposes a local session. To remove a document's published artifacts from the share, the publisher exposes:

text
bool CloseDistributedDocument(FileShareConfig fileShareConfig, string token)

Pass the same FileShareConfig the document was published with.

Setting-by-setting mapping

PreviousNow
UseDoconutWebFarm(new WebFarmOptions { … })PublishAsync(...) on the write side plus UseDoconutCloud<FileShareCloudHandler>(...) on the read side
WebFarmOptions.PathFileShareConfig.RootPath
PageWaitTimeSeconds / StartWaitFromPageBackground publishing with DistributedDocumentPublishOptions.FirstPagePriority, plus FileShareConfig.ReadReadyPollMs
viewer.OpenDocument(...) then viewer.ExportToPng(share)one PublishAsync(...) call
viewer.ExportToCloud(cloudConfig)PublishAsync(...) with the provider's config
UseDoconutCloud(new CloudOptions { Location = … })UseDoconutCloud<THandler>(options => …, pathPrefix)
DocOptions.IsWebfarm / WebfarmPathStill present, spelled IsWebFarm / WebFarmPath — not needed on the publish path
Doconut.Configs.CloudDoconut.Clouds

Checklist

  1. Build the FileShareConfig in one helper, register it once, and use it from both halves.
  2. Point RootPath at the share you already use.
  3. Publish with PublishAsync(...); delete any ExportToPng / ExportToCloud calls.
  4. Serve with UseDoconutCloud<FileShareCloudHandler>(...) on the branch the client calls.
  5. With background publishing, turn on FirstPagePriority and retryOn409 together, and give the read side a non-zero ReadReadyPollMs.
  6. Replace the per-session check with signed access tickets — one Data Protection key ring on every node, UseDoconutDistributedDocumentSecurity() on the read branch, a ticket from the open endpoint — and do not ship UnsafeMode = true alone.
  7. Verify by opening a document on one node and requesting a late page from another.

Was this page helpful?