fix(reporting): serialize concurrent nest report exports

PDFsharp/MigraDoc layout and font state is process-wide. Parallel exports
of the same snapshot laid text out differently (for example merged words
like "Total physicalsheets" and shifted table columns). Serialize the
whole render-and-save in NestPdfWriter.Write; reports are rare.

Regression test exports one snapshot 128 times in parallel and compares
page content streams with a sequential export; it failed with dozens of
mismatches per run before the lock.
This commit is contained in:
aj
2026-09-29 14:04:53 -04:00
parent 74d2b6aa68
commit e4d64a88eb
2 changed files with 67 additions and 0 deletions
+11
View File
@@ -26,6 +26,11 @@ public static class NestPdfWriter
private static readonly XColor Fill = XColor.FromArgb(222, 222, 222);
private static readonly CultureInfo Invariant = CultureInfo.InvariantCulture;
// PDFsharp/MigraDoc layout and font state is process-wide and not safe for concurrent
// documents: parallel exports laid out text differently. Reports are rare, so serialize them.
private static readonly object RenderLock = new();
/// <summary>Thread-safe; concurrent calls are serialized.</summary>
public static void Write(NestReportSnapshot snapshot, string destination) =>
Write(snapshot, destination, null);
@@ -34,6 +39,12 @@ public static class NestPdfWriter
{
ArgumentNullException.ThrowIfNull(snapshot);
ArgumentException.ThrowIfNullOrWhiteSpace(destination);
lock (RenderLock)
WriteSerialized(snapshot, destination, wrapOutput);
}
private static void WriteSerialized(NestReportSnapshot snapshot, string destination, Func<Stream, Stream>? wrapOutput)
{
ValidateText(snapshot);
if (snapshot.Plates.Length > 1)
throw new NotSupportedException($"A report with {snapshot.Plates.Length} plate layouts is not supported by this report slice; it supports at most one layout.");
@@ -0,0 +1,56 @@
using System.Collections.Concurrent;
using System.Text;
using OpenNest.Reporting;
using PdfSharp.Pdf.IO;
namespace OpenNest.Tests.Reporting;
public sealed class NestPdfWriterConcurrencyTests : IDisposable
{
private readonly string directory = Path.Combine(Path.GetTempPath(), "opennest-pdf-concurrency-" + Guid.NewGuid().ToString("N"));
public NestPdfWriterConcurrencyTests() => Directory.CreateDirectory(directory);
public void Dispose() => Directory.Delete(directory, true);
[Fact]
public void Write_ConcurrentExportsProduceTheSameLayoutAsASequentialExport()
{
// PDFsharp/MigraDoc layout shares process-wide font state; unsynchronized concurrent
// exports produced shifted/merged words (for example "Total physicalsheets").
var snapshot = NestReportBuilder.Capture(NestReportTestData.CreateNest(), NestReportTestData.GeneratedAt);
var reference = Path.Combine(directory, "reference.pdf");
NestPdfWriter.Write(snapshot, reference);
var expected = PageContent(reference);
var mismatches = new ConcurrentBag<int>();
Parallel.For(0, 128, new ParallelOptions { MaxDegreeOfParallelism = System.Math.Max(8, Environment.ProcessorCount) }, index =>
{
var path = Path.Combine(directory, $"export-{index}.pdf");
NestPdfWriter.Write(snapshot, path);
if (PageContent(path) != expected)
mismatches.Add(index);
});
Assert.Empty(mismatches);
}
/// <summary>Page content streams only: document IDs and font-subset tags vary per export.</summary>
private static string PageContent(string path)
{
using var pdf = PdfReader.Open(path, PdfDocumentOpenMode.Import);
var text = new StringBuilder();
foreach (var page in pdf.Pages)
{
foreach (var item in page.Contents.Elements)
{
var dictionary = (item as PdfSharp.Pdf.Advanced.PdfReference)?.Value as PdfSharp.Pdf.PdfDictionary
?? item as PdfSharp.Pdf.PdfDictionary;
if (dictionary?.Stream != null)
text.Append(Encoding.Latin1.GetString(dictionary.Stream.UnfilteredValue));
}
text.Append('\f');
}
return text.ToString();
}
}