Analysis of PDF & ClosedXML Excel Optimizations

Analysis of PDF & ClosedXML Excel Optimizations

This document details the analysis and recommendations for addressing performance, memory, and thread-pool starvation issues across the PDF and Excel generation components of ErpCrystal_MFG.


1. Synchronous .Result & I/O Calls in QuestPDF Compose()

Take

We confirmed the presence of synchronous .Result calls and direct synchronous database operations inside the Compose() methods across multiple QuestPDF documents.

Findings

  • QuestPDF_ConfirmationOfBalance.cs (line 35): Calls .Result on FinancialReportData(...) from IFinancialReportRepository.
  • QuestPDF_PrintLabel.cs (lines 31, 45, 52): Calls .Result on async methods PrintLabelSubTrnDetails(...) and GetTrnIdList(...).
  • QuestPDF_Payslip.cs (lines 39, 44): Calls .Result on GetEmployeeListData(...) and PrintPayslipData(...).
  • QuestPDF_ChequePrint.cs (lines 117, 131, 141, 148, 157, 173): Calls .Result on multiple configuration fetching methods (GetLabelAcPayeePaddingInfo, etc.) inside the layout-rendering helpers ComposeHeader and ComposeDateContent.
  • QuestPDF_Voucher.cs (lines 30, 39, 40): Does not call .Result because the repository methods are synchronous interfaces, but it still performs direct database access synchronously inside Compose().

Technical Impact

QuestPDF executes Compose() synchronously during the document structure compilation and rendering phase.

  1. Thread-Pool Starvation: Performing blocking .Result or synchronous database I/O calls blocks the active ThreadPool thread. Under concurrent load, this triggers thread-pool starvation, causing significant API latency spike or timeouts.
  2. Redundant Database Queries: QuestPDF may evaluate layout delegates (like headers, footers, or table structures) multiple times to calculate pagination and layout containment. If data fetching is inside these methods, the same database queries are executed repeatedly for a single PDF.

Proposed Solution

  • Rule: Compose() and its descendants should only execute layout logic and reference pre-loaded in-memory models. No I/O, database access, or .Result should happen inside them.
  • Implementation: In the calling API Controller (e.g., FinancialReportController.cs, QrPrintController.cs, VoucherController.cs), fetch all required data asynchronously using await before instantiating the QuestPDF document. Pass the fully populated data models into the QuestPDF document’s constructor.

2. QuestPDF_ConfirmationOfBalance.cs Party Loop and State Mutation

Take

The loop structure in QuestPDF_ConfirmationOfBalance.cs is both a performance bottleneck and a serious concurrency/correctness hazard. We also confirmed that the using QRCoder; import is completely unused.

Findings

  • Class-level Field Mutation:
    PartyDetails = party;
    PartyListData = ConfirmationOfBalanceData.Where(...).ToList();
    The loop mutates these class-level fields inside Compose(), while the page delegates (ComposeHeader, ComposeContent, ComposeFooter) reference them. Because QuestPDF layout compilation happens in multiple steps, referencing mutated instance fields is a bug hazard that can cause pages to display data from the wrong party.
  • Unused Imports: using QRCoder; (line 13) is imported but never used.
  • Image Path: The logo image path is correctly resolved using _logoImagePath + "/erplogo.png".

Proposed Solution

  • State Isolation (Closures): Eliminate class-level state fields for party details. Capture party data in local block-scoped variables inside Compose() and pass them explicitly to content-generation methods using lambda closures:
    foreach (var party in PartyList)
    {
        var localParty = party;
        var localPartyData = ConfirmationOfBalanceData.Where(d => d.SubAcCode == party.SubAcCode && d.DocType != "a").ToList();
    
        container.Page(page =>
        {
            page.Header().Element(c => ComposeHeader(c, localParty));
            page.Content().Element(c => ComposeContent(c, localParty, localPartyData));
            page.Footer().Element(c => ComposeFooter(c, localParty));
        });
    }
  • Import Cleanup: Remove using QRCoder;.

3. QuestPDF_PrintLabel.cs QR Code Disposal Validation

Take

The QR disposal concern is indeed invalid. No fix is required here.

Findings

The code in QuestPDF_PrintLabel.cs (lines 70-80) uses C# 8.0 using var declarations:

using var qrGenerator = new QRCodeGenerator();
using var qrCodeData = qrGenerator.CreateQrCode(...);
using var qrCode = new PngByteQRCode(qrCodeData);

using var ensures that Dispose() is implicitly called on these objects when the enclosing method GenerateQrCodeForLabel returns. There are no leaked unmanaged or native resources here.


4. ClosedXML AdjustToContents Optimization

Take

We must prioritize replacing AdjustToContents with predefined column widths. This is a critical CPU and memory optimization for large exports.

Findings

  • AdjustToContents is called 92 times across the codebase.
  • In GstReportController.cs (lines 181, 285, 350), it is executed repeatedly for multiple worksheets in the same export flow (e.g., workSheet.Columns().AdjustToContents(6); and workSheet1.Columns().AdjustToContents(5);).
  • ItemMstController.cs has 30 separate calls to AdjustToContents().

Technical Impact

AdjustToContents() forces ClosedXML to evaluate the width of every string value in the sheet against font metrics. This requires loading graphic contexts (GDI+ or Skia) and creates high memory churn, which grows linearly with row counts.

Proposed Solution

We should prioritize refactoring the heavy exporters (ItemMst, CrewMst, GstReport, AccountMst, PurchaseAnalysis) to use fixed column widths for standard data fields:

  • Standard widths:
    • GSTIN / PAN: Width = 16
    • Document No / Code: Width = 15
    • Dates: Width = 12
    • Amounts / Quantities: Width = 15
    • Names / Particulars: Width = 45 (with Style.Alignment.WrapText = true)
  • This will yield a 5x to 10x speedup on file generation and eliminate the memory overhead associated with auto-fit measurement.

5. Eager Image Byte Preloading in QuestPDF Documents

Take

Preloading image bytes via File.ReadAllBytes in constructors is a significant memory sink and should be cleaned up across all QuestPDF documents.

Findings

QuestPDF_Voucher.cs does this on line 26:

private readonly byte[]? _headerImgBytes = !string.IsNullOrEmpty(_voucherprint.HeaderImg) && File.Exists(_voucherprint.HeaderImg) ? File.ReadAllBytes(_voucherprint.HeaderImg) : null;

A codebase-wide search reveals that 10 different QuestPDF documents follow this exact pattern:

  1. QuestPDF_Dncn.cs
  2. QuestPDF_Grn.cs
  3. QuestPDF_Indent.cs
  4. QuestPDF_Invoice.cs
  5. QuestPDF_JV.cs
  6. QuestPDF_JWMiscellaneous.cs
  7. QuestPDF_JobWork.cs
  8. QuestPDF_Payslip.cs
  9. QuestPDF_SalesOrder.cs
  10. QuestPDF_Voucher.cs

Technical Impact

Eagerly loading files into byte arrays during construction adds synchronous disk I/O to the instantiation flow and keeps large byte arrays in the managed heap.

Proposed Solution

QuestPDF has native support for image path strings (.Image(string filePath)). We should update the image container rendering logic to check if the file exists and pass the path directly:

if (!string.IsNullOrEmpty(Model.HeaderImg) && File.Exists(Model.HeaderImg))
{
    column.Item().Image(Model.HeaderImg);
}

This delegates reading to QuestPDF’s internal renderer, saving memory and eliminating early file-read overhead.