web
You’re offline. This is a read only version of the page.
close
Skip to main content

Announcements

News and Announcements icon
Community site session details

Community site session details

Session Id :

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

vishalsahijwani Profile Picture vishalsahijwani 343
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 !!!!

This was originally posted here.

Comments

*This post is locked for comments