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. Doconut 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.

A single node does not need any of this: the normal OpenDocumentAsync path keeps the document in that node's memory and binds it to the browser session (see Sessions & Security). Use this page when page requests can land on a node that never saw the open.

How the two halves fit

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.

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.

Read side — serve from the share

Mount the FileShare handler on the branch the client calls — /doconut, matching BasePath: 'doconut' — behind the ticket check:

csharp
var app = builder.Build();

app.UseDoconutResources();
app.Map("/doconut", branch =>
{
    branch.UseDoconutDistributedDocumentSecurity();   // signed tickets — see below
    branch.UseDoconutCloud<FileShareCloudHandler>(options =>
    {
        options.Location          = CloudLocation.FileShare;
        options.CloudUploadConfig = app.Services.GetRequiredService<FileShareConfig>();
    }, "/");
});

UseDoconutCloud takes the provider as its type argument. 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:   'doconut',
    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.

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.

Checklist

  1. Build the FileShareConfig in one helper, register it once, and use it from both halves.
  2. Point RootPath at a folder every node can reach.
  3. Publish with PublishAsync(...), or EnqueueAsync(...) with background publishing.
  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.

Ήταν αυτή η σελίδα χρήσιμη;