Friday, September 4, 2026

Importing Excel Dates in D365 F&O through X++ without the Apostrophe Trick

 We often get a requirement to create excel upload custom functionality in x++ . In this post we will see how to handle Excel OLE Automation date serial numbers in X++ so your users never have to format a cell before uploading again.

If you have ever built an Excel import in D365 F&O and handed it to a business user, you have almost certainly received this complaint:

"The dates are not importing correctly. They are showing as random numbers."

The usual workaround developers give is: "Format the date column as Text in Excel and add an apostrophe before each date value."

That is a terrible user experience. No end user should have to manipulate cell formats before uploading a file. It also breaks down immediately when someone opens a saved template and Excel re-formats the cells automatically.

In this article I am going to explain exactly why this happens — what Excel is actually storing in a date cell and why your X++ code reads it as a 5-digit number and show you the clean, code-side solution that handles both cases automatically with no user action required.

Why date cells in Excel come through as 5-digit numbers

When you type a date into an Excel cell — say 03/06/2026 — Excel does not store that as a date string. It stores it as a floating-point number called an OLE Automation Date (also called a serial date or OADate).

The OLE Automation date system counts the number of days since a fixed epoch. Microsoft Excel uses 1 January 1900 as day 1. This means:
Date in Excel cell → What Excel actually stores internally 01/01/1900 → 1 01/01/2000 → 36526 03/06/2026 → 46145 31/12/2026 → 46388 The value is a floating-point number. The integer part = days since 01/01/1900 The decimal part = time of day (0.5 = noon, 0.75 = 18:00, etc.) When you read a date cell via EPPlus in X++ using .value: range.get_Item(i, 8).value You get back: "46145" ← a string of the integer OLE date number Not: "03/06/2026"

This is the root cause. The X++ library reads the raw underlying value of the cell, not its formatted display string. A cell that shows 03/06/2026 on screen returns "46145" when you call .value on it in code.

The standard workaround prefixing cells with an apostrophe ( ' ) forces Excel to store the cell content as a text string rather than a number. When X++ reads a text cell, it returns the formatted string. This works, but it is fragile and puts the burden on the user.

The code-side solution is to detect when the value is an OLE serial number and convert it back to a real date in X++.


The detection and conversion logic is explained below : -

The key insight is that an OLE Automation date serial number for any reasonable business date (year 2000 onwards) will always be a 5-digit integer. A real date string formatted as text (01/06/2026 or 2026-06-03) is never 5 characters long.

This gives us a reliable discriminator:
Read cell value as string │ ▼ Is strLen(value) == 5? │ ├── YES → It is an OLE serial number │ str2int() converts "46145" to integer 46145 │ any2date(46145) converts OLE integer to X++ date │ → date value ready to use │ └── NO → It is already a formatted date string Use str2Date() or pass through directly → date value ready to use
Here is the full implementation from the import class, with detailed explanations of every step:


/// <summary>
/// Converts an Excel cell value to a formatted date string.
/// Handles both cases:
///   1. OLE Automation serial number (e.g. "46145") — Excel stores dates this way
///   2. Already a formatted date string (e.g. "03/06/2026")
///
/// Why strLen == 5:
///   OLE dates from year 1900 onwards produce 5-digit integers.
///   Any real date formatted as text (dd/mm/yyyy, yyyy-mm-dd, etc.)
///   is always longer than 5 characters.
///   This makes strLen == 5 a reliable discriminator.
///
/// Why any2date(str2int(...)):
///   str2int() parses the string to an X++ integer (e.g. 46145)
///   any2date() interprets that integer as an OLE Automation date
///   and converts it to an X++ date.
///   This is the same conversion Excel performs internally when
///   displaying the number as a date.
/// </summary>
private str getDateFromString(str _dateStringField)
{
    if (strLen(_dateStringField) == 5)
    {
        // OLE serial number detected
        // Convert: "46145" → int 46145 → X++ date
        date convertedDate = any2date(str2int(_dateStringField));

        // Return as formatted string for further processing
        // Note: the - 2 adjustment is explained below in the pitfalls section
        return strFmt("%1", DateTimeUtil::date(convertedDate) - 2);
    }
    else
    {
        // Already a formatted date string — return as-is
        return _dateStringField;
    }
}

/// 
/// Same logic — returns an X++ date type directly instead of a string.
/// Use this when the target field is a date type, not a string type.
/// 
private date getDate(str _dateStringField)
{
    date convertedDate;

    if (strLen(_dateStringField) == 5)
    {
        convertedDate = any2date(str2int(_dateStringField));
    }
    // If not a 5-char string, convertedDate remains dateNull()
    // Caller should handle the dateNull() case

    return convertedDate;
}
✅ Two methods for two use cases

The class has two variants of the same logic: getDateFromString() returns a str for fields that are stored as strings in the staging table, and getDate() returns an X++ date type for fields that map directly to a date column. Always use the one that matches the type of the target table field to avoid unnecessary type conversions.


How any2date() works with OLE serial numbers

The any2date() function in X++ interprets an integer as an OLE Automation date — the same epoch that Excel uses. This is not a coincidence: Microsoft standardised on this format across Office, COM automation, and the .NET DateTime.FromOADate() method for exactly this reason.
OLE Serial Numberany2date() resultCalendar date
4492701/01/20231 January 2023
4529201/01/20241 January 2024
4565801/01/20251 January 2025
4602201/01/20261 January 2026
4614503/06/20263 June 2026
4638831/12/202631 December 2026
You can verify any of these in Excel by typing the number into a cell and formatting it as a Date.

Handling datetime columns — the System.DateTime.FromOADate approach

The code also handles a more complex scenario — when the Excel column contains a datetime value with a time component (for example, a posting date that includes the time of day). In this case, the OLE value is a floating-point number, not a pure integer. The run() method handles this using System.DateTime::FromOADate() through CLR interop:

// For datetime columns where the OLE value has a decimal component
// (time component stored as fraction of a day)
// Example: 46145.375 = 03/06/2026 09:00:00

real pDateval;
utcdatetime pDatetimeval;

// Read the cell value as a real number (handles the decimal time portion)
pDateval = any2real(range.get_Item(i, 2).value);

// Convert OLE date (real/double) to .NET DateTime using CLR interop
// System.DateTime::FromOADate() is the exact inverse of
// the OLE Automation date encoding
System.DateTime postingDateTime = System.DateTime::FromOADate(pDateval);

// Convert .NET DateTime to D365 FO UTC datetime
pDatetimeval = Global::clrSystemDateTime2UtcDateTime(postingDateTime);

// Extract just the date component if needed
invoiceIntegration.PostingDate = DateTimeUtil::date(pDatetimeval);

✅ When to use which approach

Use any2date(str2int(value)) for pure date columns where no time component is expected — it is simpler and has no CLR interop overhead. Use System.DateTime::FromOADate(any2real(value)) for datetime columns where the time component matters, such as posting dates that carry both a date and time of day.


The complete import class with all patterns in context

Below is the full, cleaned-up import class showing how both date handling approaches are used together in a real Excel import scenario. The class reads a vendor accessorial invoice spreadsheet and stages the data into a custom interface table.

using System.IO;
using OfficeOpenXml;
using OfficeOpenXml.ExcelPackage;
using OfficeOpenXml.ExcelRange;
class ImportAccessorialInvoice
{
    private ItemId findItem(str _SiteBucket, str _InvoiceType)
    {
        return CustomItemMapping::findItemByParm(MappingType::Invoice, _SiteBucket, _InvoiceType);
    }
    private str getDateFromString(str _dateStringField)
    {
        if(strLen(_dateStringField) == 5)
        {
            date convertedDate = any2date(str2int(_dateStringField));
            return strFmt("%1",  DateTimeUtil::date(convertedDate) - 2);
        }
        else
            return _dateStringField;
    }
    private date getDate(str _dateStringField)
    {
        date convertedDate;
        if(strLen(_dateStringField) == 5)
        {
            convertedDate = any2date(str2int(_dateStringField));
        }

        return convertedDate;
    }
    void run()
    {
        real                                                        pDateval;
        utcdatetime                                                 pDatetimeval;
        System.IO.Stream                                            stream;
        ExcelSpreadsheetName                                        sheeet;
        FileUploadBuild                                             fileUpload;
        DialogGroup                                                 dlgUploadGroup;
        FileUploadBuild                                             fileUploadBuild;
        FormBuildControl                                            formBuildControl;
        COMVariantType                                              type;
        POInvoiceInterface                                          invoiceIntegration , invoiceIntegrationdel;
        Dialog                                                      dialog =    new Dialog("@ImportPOInvoice");
        dlgUploadGroup          = dialog.addGroup('@SYS54759');
        formBuildControl        = dialog.formBuildDesign().control(dlgUploadGroup.name());
        fileUploadBuild         = formBuildControl.addControlEx(classstr(FileUpload), "@InvoiceUpload");
        fileUploadBuild.style(FileUploadStyle::MinimalWithFilename);
        fileUploadBuild.fileTypesAccepted('.xlsx');
        str COMVariant2Str(COMVariant _cv)
        {
            switch (_cv.variantType())
            {
                case COMVariantType::VT_BSTR:
                    return _cv.bStr();
                case COMVariantType::VT_EMPTY:
                    return '';
                default:
                    throw error(strfmt('@SYS26908', _cv.variantType()));
            }
        }
        if (dialog.run() && dialog.closedOk())
        {
            FileUpload fileUploadControl     = dialog.formRun().control(dialog.formRun().controlId('Upload'));
            FileUploadTemporaryStorageResult fileUploadResult = fileUploadControl.getFileUploadResult();
            if (fileUploadResult != null && fileUploadResult.getUploadStatus())
            {
                stream = fileUploadResult.openResult();
                using (ExcelPackage Package = new ExcelPackage(stream))
                {
                    int                         rowCount, i,columncount,j;
                    Package.Load(stream);
                    ExcelWorksheet   worksheet   = package.get_Workbook().get_Worksheets().get_Item(1);
                    OfficeOpenXml.ExcelRange    range       = worksheet.Cells;
                    rowCount           = (worksheet.Dimension.End.Row) - (worksheet.Dimension.Start.Row)  + 1;
                    columncount      = (worksheet.Dimension.End.Column);
                    ttsbegin;
                    delete_from invoiceIntegrationdel where invoiceIntegrationdel.HasValidationErrors==NoYes::Yes;
                    for (i = 2; i<= rowCount; i++)
                    {
                        invoiceIntegration.clear();
                        POInvoiceIntegrationInterface        pOInvoiceIntegrationCounter;

                        select maxof(RowNum) from pOInvoiceIntegrationCounter
                            index hint RowNumIdx;
                        invoiceIntegration.RowNum      = pOInvoiceIntegrationCounter.RowNum + 1;
                        invoiceIntegration.Invoice = range.get_Item(i, 7).value;
                        invoiceIntegration.LocationID = range.get_Item(i, 9).value;
                        invoiceIntegration.CarrierName = range.get_Item(i, 3).value;
                        invoiceIntegration.CarrierProNumber = range.get_Item(i, 4).value;
                        invoiceIntegration.InvoiceDate = this.getDateFromString(range.get_Item(i, 8).value);
                        invoiceIntegration.LoadNumberFile = range.get_Item(i, 6).value;
                        invoiceIntegration.AccessorialsLessFuelSurcharge = range.get_Item(i, 10).value;
                        invoiceIntegration.ACCode  =   range.get_Item(i, 13).value;
                        invoiceIntegration.ACDescription   = range.get_Item(i, 14).value;
                        invoiceIntegration.Department      = range.get_Item(i, 15).value;
                        invoiceIntegration.CostCenter      = range.get_Item(i, 16).value;
                        invoiceIntegration.Purpose         = range.get_Item(i, 17).value;
                        invoiceIntegration.VendAccount     = range.get_Item(i, 1).value;

                        pDateval = any2real(range.get_Item(i, 2).value);
                        System.DateTime postingDateTime = System.DateTime::FromOADate(pDateval);
                        pDatetimeval = Global::clrSystemDateTime2UtcDateTime(postingDateTime);
                        invoiceIntegration.PostingDate = DateTimeUtil::date(pDatetimeval);

                        invoiceIntegration.AccessoriaApprovalNumber = range.get_Item(i, 5).value;
                        invoiceIntegration.AccessoriaApprovalNumber = invoiceIntegration.AccessoriaApprovalNumber ? invoiceIntegration.AccessoriaApprovalNumber : PurchParameters::find().CustomDefaultApprovalNumber;
                        invoiceIntegration.ItemId                   = this.findItem(range.get_Item(i, 11).value, range.get_Item(i, 12).value);
                        invoiceIntegration.PurchPrice               = range.get_Item(i, 10).value;

                        // Generate Row Number
                        invoiceIntegration.validateAccessorialInvoice();
                        if(invoiceIntegration.PurchPrice)
                        {
                            invoiceIntegration.insert();
                        }

                    }
                    ttscommit;
                    info("@ImportCompleted");
                }
            }
            else
            {
                error("@ImportError");
            }
            this.openStagingInterfaceForm();
        }
    }
    public static void main (Args args)
    {
        ImportPOInvoice importInvoice = new ImportPOInvoice();
        importInvoice.run();
    }
    private void openStagingInterfaceForm()
    {
        Args            args = new Args();
        MenuFunction    menuFunction;
        menuFunction = new MenuFunction(
                        menuItemDisplayStr(InterfaceForm1),
                        MenuItemType::Display);
        menuFunction.run(args);
    }
}


The - 2 adjustment — what it is and when you need it



You may have noticed this line in getDateFromString():

return strFmt("%1", DateTimeUtil::date(convertedDate) - 2);


The - 2 is a date correction that accounts for two known quirks in the OLE Automation date system:

1. The 1900 leap year bug: Excel incorrectly treats 1900 as a leap year and
includes February 29, 1900 in its day count — a date that never existed.
This adds 1 extra day to all Excel OLE dates from March 1900 onwards.

2. Epoch difference: The X++ any2date() function and the Excel OLE system may use a slightly
different epoch start in certain version combinations, adding another 1-day offset.


The strLen == 5 boundary — what about years before 2000?

A question worth addressing: what if someone imports a date from before year 2000 where the OLE serial number is a
4-digit number?

DateOLE serialstrLenHandled by
01/01/1995347005✅ getDateFromString() — 5-char check catches it
01/01/1999361615✅ getDateFromString() — correctly detected
01/01/2000365265✅ getDateFromString() — correctly detected
31/12/2099730505✅ getDateFromString() — correctly detected
01/01/190011❌ Falls through to else — treated as date string
01/01/1970255695✅ Correctly detected


For all practical business date scenarios — any date from 1995 onwards — the OLE serial number is always 5 digits. Dates before
approximately 1927 produce 4-digit serials, but these are outside any realistic AP invoice date range. The strLen == 5 check is
safe for all real-world financial document dates.

Why this pattern is better than asking users to format cells


The apostrophe approach — Problems in practice users generally forget. Every time someone gets a fresh copy of the template
or opens it in a different version of Excel, the cell format may reset.When cells are formatted as Text, Excel shows a green warning
triangle on every cell. Users call this a bug.Pasting dates from another system into a text-formatted cell in Excel pastes the raw text
with the apostrophe visible — producing values like '03/06/2026 in the import.When the template is filled by an automated
system or another X++ export, it will never add apostrophes — breaking the entire import pipeline.

The code-side approach — Why it is correct users upload the file exactly as Excel saved it — no cell formatting required.Works
whether the user typed the date, pasted it, or it was generated by another system.Works for both date and datetime columns using
two different but equally clean conversion paths.The logic is in the import class where it belongs — not in a user instruction
document that no one reads.



Conclusion :-


The reason Excel date cells come through as 5-digit numbers in X++ EPPlus imports is well-understood: Excel stores dates as
OLE Automation serial numbers, and EPPlus reads the raw underlying value rather than the formatted display string. The fix is
entirely on the code side.

For pure date columns, any2date(str2int(cellValue)) converts the serial number back to an X++ date in two function calls. For
datetime columns where the time component matters, System.DateTime::FromOADate(any2real(cellValue)) followed by
Global::clrSystemDateTime2UtcDateTime() handles the full precision conversion through CLR interop.

The strLen == 5 check is the discriminator that makes both approaches safe — it detects OLE serials reliably
for all dates from 1995 to 2099 without any false positives against real date strings. Test the - 2 epoch adjustment in your own
environment before going to production, and your users will never need to touch a cell format again.


That's all for now. Please let us know your questions or feedback in comments section !!!!

Tuesday, August 11, 2026

Implementation of Multithreading in D365 F&O through X++

A single SysOperation batch job is enough for most scenarios. If your batch job processes 10,000 records in 20 minutes and that is acceptable for your business, you do not need multithreading.

But there are real situations where a single thread is not enough — a high-volume eCommerce integration pushing thousands of sales orders per hour into a staging table, a month-end calculation running across hundreds of thousands of ledger entries, or a migration batch that needs to complete in a maintenance window rather than across multiple days.

In these situations, multithreading in D365 F&O — running multiple batch tasks in parallel across available AOS batch threads — can compress hours of processing into minutes. Done correctly, it scales linearly with the number of threads. Done incorrectly, it either processes the same records multiple times or has threads sitting idle while one thread does all the work.

This article covers the complete implementation from the beginning: the three approaches to parallelism, the full Top Picking pattern end to end with verified X++ code, how pessimisticlock and readPast actually work together to prevent duplicate processing, production error handling, and how to verify your threads are genuinely working in parallel.

Before you reach for multithreading — check these first

Multithreading adds architectural complexity. Before implementing it, verify these simpler optimisations are already in place — they often resolve performance problems without parallelism:

CheckWhy it matters
Set-based operations instead of row-by-row while selectupdate_recordset, insert_recordset, and delete_from can be 10–100x faster than looping. Adding threads to a slow loop does not fix the underlying problem.
Index coverage on your queryA missing index on the fields in your where clause causes a full table scan on every iteration. Adding the right index can reduce batch time from hours to minutes without any code change.
Correct use of firstOnly in select statementsFetching more rows than you need wastes memory and IO on every iteration.
Avoid cross-company queries in tight loopschangecompany inside a loop is expensive — pull the company context outside the loop or restructure the query.

If these are already optimised and the batch is still too slow — then multithreading is the right next step.

The three approaches to parallel batch processing

FIRST

Individual Task Modelling

Create one batch task per work item. If you have 500 records to process, you create 500 tasks. Each task processes exactly one record.

When to use: Only when you have a small, known, fixed set of work items (e.g. process 5 specific companies).

Why to avoid at scale: In D365 F&O, creating thousands of batch tasks adds significant overhead — each task has its own record in BatchJob and BatchTask tables. The batch scheduler itself becomes a bottleneck. Community benchmarks show this approach performs significantly worse than alternatives when task counts exceed the number of available batch threads.

SECOND

Batch Bundling

Divide work items into fixed-size bundles and assign each bundle to one task. If you have 1,000 records and 8 threads, each thread gets a bundle of 125 records.

When to use: When work items are known upfront and processing time per item is roughly uniform.

The problem: If item processing time varies — some sales orders have 2 lines, others have 200 — bundles finish at very different times. You end up with some threads finishing in minutes while others run for an hour, with the idle threads doing nothing. This is the thread imbalance problem.

THIRD — Recommended approach

Top Picking

Create a fixed number of tasks (threads). Each task independently picks the next unprocessed work item, processes it, marks it as done, and immediately picks the next one — until no items remain. Work is distributed dynamically at runtime, not upfront.

Why it is the best approach: Thread imbalance is impossible — a fast thread automatically processes more items than a slow one. No items are processed twice because pessimisticlock ensures only one thread can claim a given record at a time. This is the pattern used throughout the D365 F&O standard application.

The Top Picking pattern — full implementation

The architecture has four components:

┌─────────────────────────────────────────────────┐ │ Component 1: CSTMultiThreadContract │ │ DataContract class — thread count parameter │ └──────────────────────┬──────────────────────────┘ │ ┌──────────────────────▼──────────────────────────┐ │ Component 2: CSTMultiThreadController │ │ SysOperationServiceController — entry point │ │ Has Action Menu Item — user schedules this │ └──────────────────────┬──────────────────────────┘ │ creates N tasks ┌──────────────────────▼──────────────────────────┐ │ Component 3: CSTMultiThreadService │ │ Creates N batch tasks on the BatchHeader │ │ Sets InProcessing status before spawning tasks │ └──────────────────────┬──────────────────────────┘ │ each task runs ┌──────────────────────▼──────────────────────────┐ │ Component 4: CSTWorkerController + │ │ CSTWorkerService │ │ No menu item — spawned by Component 3 only │ │ pessimisticlock + readPast top-picking loop │ │ Calls CSTWorkItemProcessor per record │ └─────────────────────────────────────────────────┘

The staging table

Create a table named CSTVendStagingTable with at minimum these fields:

FieldTypeDescription
RecIdInt64Standard RecId — used as the work item identifier
ProcessingStatusEnum: CSTProcessingStatusToBeProcessed = 0, InProcessing = 1, Processed = 2, Error = 3
ErrorMessagestr 500Populated when ProcessingStatus = Error
ProcessedDateTimeUtcDateTimeWhen the record was completed
VendAccountVendAccountYour actual business data fields go here
InvoiceAmountAmountCurExample business data field
⚠️ The InProcessing status is not optional — it is critical

Without an InProcessing status, there is a race condition where threads that finish early and find no remaining records stop permanently. If new records arrive in the staging table while the remaining threads are still running, only those threads process the new records — the finished threads do not restart. The InProcessing approach snapshots all available records at job start, ensuring all threads stop together when that snapshot is exhausted, and the next job recurrence picks up any new records.

Component 1 — The DataContract class


/// <summary>/// DataContract for the multithreading coordinator batch job.
/// Exposes the thread count parameter on the batch dialog.
/// </summary>
[DataContract]
public class CSTMultiThreadContract
{
    private int numberOfThreads;

    [DataMember,
     SysOperationLabel(literalStr("Number of parallel threads")),
     SysOperationHelpText(literalStr("Maximum is 16 — see batch server configuration."))]
    public int parmNumberOfThreads(int _numberOfThreads = numberOfThreads)
    {
        numberOfThreads = _numberOfThreads;
        return numberOfThreads;
    }
}

Component 2 — The coordinator Controller class


/// <summary>/// SysOperationServiceController for the multithreading coordinator.
/// This is the class that has an Action Menu Item and that users schedule.
/// It is responsible ONLY for creating the worker tasks — it finishes in seconds.
/// </summary>
public class CSTMultiThreadController extends SysOperationServiceController
{
    protected void new()
    {
        // Points to the process() method on CSTMultiThreadService
        super(classStr(CSTMultiThreadService),
              methodStr(CSTMultiThreadService, process),
              SysOperationExecutionMode::Synchronous);
    }

    public ClassDescription defaultCaption()
    {
        return "Vendor Staging — Multithreaded Processor";
    }

    public static CSTMultiThreadController construct(
        SysOperationExecutionMode _executionMode = SysOperationExecutionMode::Synchronous)
    {
        CSTMultiThreadController controller = new CSTMultiThreadController();
        controller.parmExecutionMode(_executionMode);
        return controller;
    }

    public static void main(Args _args)
    {
        CSTMultiThreadController controller = CSTMultiThreadController::construct();
        controller.parmArgs(_args);
        controller.startOperation();
    }
}

Component 3 — The coordinator Service class

This is the most important class in the pattern. It does three things: moves records to InProcessing status (the snapshot), creates one worker task per requested thread, and saves all tasks to the batch header in a single operation.


/// <summary>
/// Service class for the coordinator batch job.
/// Responsible for:
///   1. Snapshotting records by setting them to InProcessing status
///   2. Spawning N worker tasks on the current batch header
/// This class finishes in seconds — all actual processing is done by the worker tasks.
/// </summary>
public class CSTMultiThreadService extends SysOperationServiceBase
{
    public void process(CSTMultiThreadContract _contract)
    {
        CSTVendStagingTable            stagingTable;
        SysOperationServiceController  workerController;
        BatchHeader                    batchHeader;
        int                             threadCount;
        int                             totalThreads = _contract.parmNumberOfThreads();
        RecordInsertList                logList;

        // Validate thread count — hard limit of 16 from Microsoft documentation
        if (totalThreads <= 0 || totalThreads > 16)
        {
            throw error("Number of threads must be between 1 and 16.");
        }

        // Step 1: Check whether any records are waiting to be processed
        select count(RecId) from stagingTable
            where stagingTable.ProcessingStatus == CSTProcessingStatus::ToBeProcessed;

        if (stagingTable.RecId == 0)
        {
            info("No records in ToBeProcessed status. Nothing to do.");
            return;
        }

        // Step 2: Snapshot — move all ToBeProcessed records to InProcessing
        // This prevents new records arriving mid-run from causing thread imbalance
        update_recordset stagingTable
            setting ProcessingStatus = CSTProcessingStatus::InProcessing
            where stagingTable.ProcessingStatus == CSTProcessingStatus::ToBeProcessed;

        info(strFmt("%1 records moved to InProcessing status.", stagingTable.RecId));

        // Step 3: Get the current batch header to add tasks to
        // getCurrentBatchHeader() returns the BatchHeader of the currently running batch job
        // This ensures worker tasks are children of THIS job — not independent jobs
        batchHeader = this.getCurrentBatchHeader();

        if (!batchHeader)
        {
            // Fallback: if running interactively (not as a batch), create a new header
            batchHeader = BatchHeader::construct();
        }

        // Step 4: Create one worker controller per requested thread
        for (threadCount = 1; threadCount <= totalThreads; threadCount++)
        {
            workerController = CSTWorkerController::construct();
            workerController.parmDialogCaption(
                strFmt("Vendor Staging Worker — Thread %1 of %2", threadCount, totalThreads));

            batchHeader.addTask(workerController);
        }

        // Step 5: Save all tasks at once
        batchHeader.save();

        info(strFmt("%1 worker tasks created and queued.", totalThreads));
    }
}
✅ Why getCurrentBatchHeader() instead of BatchHeader::construct()

When the coordinator runs as a batch job (which it always should), getCurrentBatchHeader() returns the BatchHeader of the running job. Tasks added to this header become child tasks of the coordinator job — they are visible under the same batch job ID and their status is tracked together. Using BatchHeader::construct() creates a new, independent batch job each time, which makes monitoring and troubleshooting much harder.

Component 4a — The worker Controller class


/// <summary>/// SysOperationServiceController for the worker tasks.
/// This class does NOT have a menu item — it is only instantiated
/// programmatically by CSTMultiThreadService.
/// </summary>
public class CSTWorkerController extends SysOperationServiceController
{
    protected void new()
    {
        super(classStr(CSTWorkerService),
              methodStr(CSTWorkerService, process),
              SysOperationExecutionMode::Synchronous);
    }

    public ClassDescription defaultCaption()
    {
        return "Vendor Staging Worker";
    }

    public static CSTWorkerController construct(
        SysOperationExecutionMode _executionMode = SysOperationExecutionMode::Synchronous)
    {
        CSTWorkerController controller = new CSTWorkerController();
        controller.parmExecutionMode(_executionMode);
        return controller;
    }

    public static void main(Args _args)
    {
        CSTWorkerController controller = CSTWorkerController::construct();
        controller.parmArgs(_args);
        controller.startOperation();
    }
}

Component 4b — The worker Service class

This is the heart of the Top Picking pattern. The combination of readPast(true), pessimisticlock, and firstOnly is what makes parallel processing safe.


/// <summary>/// Service class for each worker task.
/// Uses pessimistic locking with readPast to implement Top Picking:
///   - pessimisticlock: locks the selected record so no other thread can select it
///   - readPast(true): skips records that are locked by other threads
/// This combination guarantees each record is processed by exactly one thread.
/// </summary>
public class CSTWorkerService extends SysOperationServiceBase
{
    public void process()
    {
        CSTVendStagingTable stagingTable;

        // readPast(true) is the critical call that enables Top Picking.
        // When true: if this thread tries to select a record that is locked
        // by another thread, it skips that record and moves to the next one.
        // Without this, the thread would wait (block) until the lock is released,
        // causing all threads to queue up behind the same records.
        stagingTable.readPast(true);

        do
        {
            try
            {
                ttsBegin;

                // pessimisticlock: locks the selected record at the database level.
                // firstOnly: gets exactly one record.
                // Together: this thread claims one record that no other thread can claim.
                select pessimisticlock firstOnly stagingTable
                    where stagingTable.ProcessingStatus == CSTProcessingStatus::InProcessing;

                if (stagingTable)
                {
                    // Process the claimed record
                    CSTWorkItemProcessor::processRecord(stagingTable);

                    // Mark as processed within the same transaction as the lock
                    stagingTable.ProcessingStatus   = CSTProcessingStatus::Processed;
                    stagingTable.ProcessedDateTime  = DateTimeUtil::utcNow();
                    stagingTable.update();
                }

                ttsCommit;
                // Lock is released on ttsCommit — other threads can now access
                // the next InProcessing records
            }
            catch (Exception::Deadlock)
            {
                // SQL Server deadlock — safe to retry immediately
                // The transaction is automatically rolled back on deadlock
                ttsAbort;
                retry;
            }
            catch (Exception::UpdateConflict)
            {
                // Optimistic concurrency conflict — retry
                ttsAbort;
                if (appl.ttsLevel() == 0)
                {
                    retry;
                }
                else
                {
                    throw Exception::UpdateConflict;
                }
            }
            catch (Exception::Error)
            {
                // Business logic error on this specific record
                // Do NOT re-throw — mark the record as Error and continue
                // to the next record so one bad record does not kill the thread
                ttsAbort;

                // Re-select for update outside the failed transaction
                CSTVendStagingTable errorRecord;
                select forUpdate firstOnly errorRecord
                    where errorRecord.RecId == stagingTable.RecId;

                if (errorRecord)
                {
                    ttsBegin;
                    errorRecord.ProcessingStatus = CSTProcessingStatus::Error;
                    errorRecord.ErrorMessage     = infolog.text();
                    errorRecord.update();
                    ttsCommit;
                }
            }
        }
        while (stagingTable.RecId != 0); // Loop until no InProcessing records remain
    }
}
⚠️ The readPast(true) call must be OUTSIDE the do-while loop

readPast(true) sets a property on the table buffer object. It needs to be set once before the loop begins — not inside the loop on each iteration. Setting it inside the loop still works but is misleading about its scope. Placing it outside makes it clear that it applies to all selects on that buffer throughout the entire loop execution.

⚠️ Never use ttsBegin/ttsCommit outside the do-while when processing records

Wrapping the entire do-while loop in a single transaction means one failed record rolls back every record processed by that thread since the transaction began. Each record must be processed in its own transaction — ttsBegin inside the loop, ttsCommit after the update, and ttsAbort on error. The lock is held for exactly one record at a time.

Component 5 — The work item processor

Separating the actual business logic into its own class is not just good practice — it enforces that each work item is truly autonomous. If your logic cannot fit cleanly into a standalone static method called with a single table buffer, that is a signal that your work item definition is too large or too dependent on other records.


/// <summary>/// Processes a single record from CSTVendStagingTable.
/// This class is intentionally NOT a SysOperation class — it is a pure
/// business logic class called by CSTWorkerService.
/// Keep all logic for one work item here — autonomous and self-contained.
/// </summary>
public class CSTWorkItemProcessor
{
    public static void processRecord(CSTVendStagingTable _stagingRecord)
    {
        VendTable   vendTable;
        VendTrans   vendTrans;

        // Validate the vendor exists before processing
        vendTable = VendTable::find(_stagingRecord.VendAccount);

        if (!vendTable)
        {
            throw error(strFmt("Vendor %1 not found. Record %2 cannot be processed.",
                _stagingRecord.VendAccount, _stagingRecord.RecId));
        }

        // Your actual business logic goes here
        // This example creates a vendor transaction record from staging data
        // In a real implementation this would be your order creation,
        // data validation, GL posting, or whatever the batch job is doing

        ttsBegin;

        vendTrans.AccountNum    = _stagingRecord.VendAccount;
        vendTrans.TransDate     = today();
        vendTrans.AmountMST     = _stagingRecord.InvoiceAmount;
        vendTrans.TransType     = LedgerTransType::Purch;
        // ... set other required fields
        vendTrans.insert();

        ttsCommit;
    }
}

How pessimisticlock and readPast work together

This is the mechanism that prevents duplicate processing. Understanding it at the database level explains why the pattern works and what happens when it is implemented incorrectly.

Thread A Thread B ──────────────────────────────────────────────────── ttsBegin ttsBegin SELECT TOP 1 WITH (UPDLOCK) SELECT TOP 1 WITH (UPDLOCK, READPAST) FROM CSTVendStagingTable FROM CSTVendStagingTable WHERE ProcessingStatus = 1 WHERE ProcessingStatus = 1 ── Gets RecId 1001 ── RecId 1001 is LOCKED by Thread A ── Locks RecId 1001 ── READPAST skips it ── Gets RecId 1002 ── Locks RecId 1002 Processes RecId 1001 Processes RecId 1002 UPDATE RecId 1001 → Processed UPDATE RecId 1002 → Processed ttsCommit ttsCommit ── Lock on 1001 released ── Lock on 1002 released Next iteration: Gets RecId 1003 Next iteration: Gets RecId 1004

The SQL translation is exact: pessimisticlock in X++ generates WITH (UPDLOCK) in the SQL query. readPast(true) adds READPAST to the hint. The combination — WITH (UPDLOCK, READPAST) — is the standard SQL Server pattern for queue processing: lock what you claim, skip what others have claimed.

⚠️ Without readPast(true), threads block each other instead of skipping

If you use pessimisticlock without readPast(true), Thread B does not skip RecId 1001 — it waits for Thread A to release the lock. Both threads then race to claim RecId 1002. This causes lock contention, threads effectively serialise behind each other, and you get no parallelism benefit. The readPast(true) call is what makes the pattern genuinely parallel.

Configuring the batch server for maximum threads

Creating 8 worker tasks in code does not guarantee 8 tasks run simultaneously. The batch server has a configurable maximum thread limit.

  1. Navigate to System Administration → Setup → Server Configuration
  2. Find the AOS instance that runs your batch jobs
  3. Set Maximum batch threads — Microsoft's documented maximum is 16
  4. Save and restart the batch service if required
⚠️ Setting threads higher than 16 has documented negative consequences

Microsoft's official documentation states that setting Maximum batch threads above 16 can have negative performance consequences on the AOS instance and the SQL Server database. The AOS is not designed to scale batch parallelism beyond this limit. More threads above 16 does not mean faster processing — it means more contention for SQL connections, more lock waits, and potential AOS instability.

Verifying your threads are genuinely running in parallel

After running the coordinator job, navigate to System Administration → Inquiries → Batch jobs. Find the coordinator job by its description. Click the Job ID link to drill into the tasks.

You should see N tasks — one per thread you specified — all showing status Executing simultaneously. When processing is complete they will all move to Ended.

Performance verification calculation is mentioned below : - 

Scenario: 1,000 records, each takes ~2 seconds to process Single thread expected time: 1,000 × 2s = ~2,000 seconds (~33 minutes) 8 threads expected time: 2,000s ÷ 8 = ~250 seconds (~4 minutes) What you actually see: First task started: 09:00:00 Last task ended: 09:04:18 Total elapsed: 258 seconds ✓ — multithreading is working Red flag — multithreading is NOT working: Total elapsed: ~2,000 seconds Root cause check 1: Is readPast(true) set on the buffer? Root cause check 2: Is Maximum batch threads > 1 on the server? Root cause check 3: Are all tasks assigned to the same batch group?
⚠️ Only one thread processing while others sit idle — the most common symptom

This is reported frequently in the D365 community. The three most common causes are: (1) readPast(true) is missing — threads block instead of skip; (2) the batch server Maximum batch threads is set to 1; (3) all tasks are in a batch group that only one AOS instance serves. Check all three before assuming the code is wrong.

Complete class list for your Visual Studio project

ClassTypeMenu Item?Purpose
CSTProcessingStatusEnumToBeProcessed, InProcessing, Processed, Error
CSTVendStagingTableTableStaging table with ProcessingStatus field
CSTMultiThreadContractClass (DataContract)Thread count parameter
CSTMultiThreadControllerClass (SysOperationServiceController)✅ Action Menu ItemUser-facing coordinator — schedules and spawns tasks
CSTMultiThreadServiceClass (SysOperationServiceBase)Snapshots records, creates worker tasks
CSTWorkerControllerClass (SysOperationServiceController)❌ No menu itemWorker task controller — spawned only by CSTMultiThreadService
CSTWorkerServiceClass (SysOperationServiceBase)Top-picking loop with pessimisticlock + readPast
CSTWorkItemProcessorClass (plain)Business logic for one record — autonomous and standalone

Conclusion :-

Multithreading in D365 F&O batch jobs is not architecturally complex once you understand the pattern. The coordinator creates tasks. The worker tasks use pessimisticlock + readPast(true) to claim and process one record at a time without conflicts. The InProcessing status snapshots the work at job start, preventing thread imbalance from mid-run record arrivals.

The most important implementation detail is the combination of pessimisticlock and readPast(true). Without both, the pattern either causes duplicate processing or serialises threads behind locks. With both, each thread independently and safely claims work items, and the parallelism scales linearly up to the configured batch thread maximum of 16.

Before implementing multithreading, always verify that set-based operations and index optimisation have been applied first. Parallelising a slow, unoptimised batch job gives you multiple slow threads instead of one — the underlying performance problem remains. Multithreading is the right answer when a well-optimised single-threaded batch job is still too slow for the volume. In that scenario, the Top Picking pattern in this article will compress hours of processing into minutes.


That's all for now. Please let us know your questions or feedback in comments section !!!!

Importing Excel Dates in D365 F&O through X++ without the Apostrophe Trick

  We often get a requirement to create excel upload custom functionality in x++ . In this post we will see how to handle Excel OLE Automatio...