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
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 pageThe 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:
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.
| Property | Purpose |
|---|---|
Location | CloudLocation.FileShare for a UNC/local share |
RootPath | The shared folder every node can reach |
UsePerTokenSubfolders | Keep each document's artifacts in its own {token} folder (default true) |
ReadReadyPollMs | How 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:
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:
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:
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:
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:
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.
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:
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:
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:
bool CloseDistributedDocument(FileShareConfig fileShareConfig, string token)Pass the same FileShareConfig the document was published with.
Checklist
- Build the
FileShareConfigin one helper, register it once, and use it from both halves. - Point
RootPathat a folder every node can reach. - Publish with
PublishAsync(...), orEnqueueAsync(...)with background publishing. - Serve with
UseDoconutCloud<FileShareCloudHandler>(...)on the branch the client calls. - With background publishing, turn on
FirstPagePriorityandretryOn409together, and give the read side a non-zeroReadReadyPollMs. - 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 shipUnsafeMode = truealone. - Verify by opening a document on one node and requesting a late page from another.
Была ли эта страница полезной?