Domain 7 β€” Module 2 of 3 67%
27 of 28 overall
Domain 7: Implement Security and Optimize Performance Free ⏱ ~16 min read

Performance, Caching & Set-Based Operations

Optimise F&O performance with table caching strategies, TempDB vs InMemory temp tables, set-based operations (insert_recordset, update_recordset, delete_from), and concurrency control patterns.

Why performance tuning matters in F&O

Simple explanation

Think of a busy warehouse.

If every worker walks to the central filing cabinet to look up a part number for every single item they pick, the whole warehouse grinds to a halt. Smart warehouses keep frequently-used lists nearby (caching), process entire pallets at once instead of one box at a time (set-based operations), and have rules for who can grab the last item (concurrency control).

F&O performance works the same way. Cache lookup tables so you don’t hit the database for every row. Process thousands of records in one SQL statement instead of looping. And handle conflicts when two users update the same record.

Table caching

Cache types

F&O supports three table caching modes, configured on the table’s CacheLookup property:

Cache types β€” from most selective (Found) to most aggressive (EntireTable)
Cache TypeWhat it cachesCache miss behaviourBest for
FoundRecords found in previous lookupsQueries the database, does NOT cache missesTables where records exist for most lookups (CustTable, VendTable)
FoundAndEmptyFound records AND 'not found' resultsQueries the database, caches the miss tooTables where many lookups return no result (config tables with sparse data)
EntireTableThe entire table loaded into memory at first accessN/A β€” all data is already cachedSmall reference tables (<500 rows) that rarely change (Units, Currencies, Parameters)
NoneNothing cachedAlways hits the databaseLarge transactional tables (SalesTable, InventTrans) β€” too big/volatile to cache
Exam tip: EntireTable pitfalls

EntireTable caching is powerful but dangerous:

  • Only use on small, rarely-changing tables (parameters, units, currencies)
  • The entire table is loaded into each AOS instance’s memory β€” a 100K-row table wastes memory across every server
  • Changes to the table invalidate the entire cache on all AOS instances β€” frequent updates defeat the purpose
  • The cache is per-company for tables with SaveDataPerCompany = Yes

Exam pattern: β€œA developer sets EntireTable on a table with 50,000 records and frequent inserts. What’s the problem?” β†’ Memory bloat + constant cache invalidation = worse performance than no caching.

When caching works (and doesn’t)

Caching works wellCaching hurts
Small lookup tables (< 500 rows)Large transactional tables (millions of rows)
Data changes infrequentlyData changes frequently (invalidation thrashing)
Lookups use the table’s unique indexQueries use non-indexed fields or complex joins
Read-heavy workloadsWrite-heavy workloads
Key rule: Caching only works with unique index lookups

Critical: Table caching in F&O only activates when the query uses the table’s unique index columns. If you query by non-indexed fields, the cache is bypassed and the database is hit directly.

// βœ… Cache HIT β€” lookup by primary key (unique index)
select firstOnly custTable
    where custTable.AccountNum == '1001';

// ❌ Cache MISS β€” lookup by non-indexed field
select firstOnly custTable
    where custTable.CustGroup == 'RETAIL';

This is one of the most commonly tested performance concepts.

SysGlobalObjectCache

For custom caching needs beyond table-level caching, F&O provides SysGlobalObjectCache β€” a shared, in-memory key-value store across the AOS.

// Writing to global cache
SysGlobalObjectCache cache = new SysGlobalObjectCache();
container key = ['ExchangeRate', 'NZD', 'USD'];
container value = [1.62, today()];
cache.insert(classStr(MyExchangeRateHelper), 0, key, value);

// Reading from global cache
container cachedValue = cache.find(
    classStr(MyExchangeRateHelper), 0, key);
if (cachedValue != conNull())
{
    Real rate = conPeek(cachedValue, 1);
    // Use cached rate
}
else
{
    // Cache miss β€” fetch from source and cache it
}
FeatureDetail
ScopeShared across all sessions on the same AOS instance
KeyContainer β€” can be multi-part (class name + scope + key fields)
ValueContainer β€” any serialisable data
InvalidationManual β€” you must explicitly remove or update entries when data changes
LifetimeUntil AOS restart or manual invalidation

Singleton pattern

For objects that should be instantiated once and reused (e.g. configuration readers), the singleton pattern avoids repeated construction:

class PFConfigReader
{
    private static PFConfigReader instance;
    private PFConfigTable configData;

    private void new()
    {
        // Private constructor β€” can't be instantiated directly
        select firstOnly configData from configData;
    }

    public static PFConfigReader getInstance()
    {
        if (!instance)
        {
            instance = new PFConfigReader();
        }
        return instance;
    }

    public PFConfigTable getConfig()
    {
        return configData;
    }
}

TempDB vs InMemory temp tables

Choosing the right temp table type has a significant performance impact:

InMemory = small and fast in AOS memory. TempDB = large and powerful in SQL Server.
AspectInMemoryTempDB
Storage locationApplication server (AOS) memorySQL Server tempdb database
SQL capabilityLimited β€” no joins in select, no set-based opsFull SQL β€” joins, set-based ops, aggregations all work
Data volumeSmall datasets (< 1,000 rows)Large datasets (thousands to millions of rows)
Performance profileFast for small data (no network round trip)Fast for large data (SQL engine handles bulk ops)
Cross-tierCannot be passed between client and server tiersCan be passed between tiers (serialised to SQL)
Set-based support❌ No insert_recordset, update_recordsetβœ… Full set-based operation support
SSRS reportsSupported as RDP temp tableSupported as RDP temp table (preferred for large reports)
Table propertyTableType = InMemoryTableType = TempDB
Vik's decision rule

Vik Kapoor at Axion Dynamics has a simple rule:

β€œIf the temp table will hold more than a few hundred rows, or if I need set-based operations or joins β€” TempDB. If it’s a tiny buffer for passing a handful of records between methods β€” InMemory.”

β€œAnd for SSRS reports,” Vik adds, β€œalways prefer TempDB. Reports often process thousands of records, and you want insert_recordset to populate the temp table efficiently.”

Set-based operations

This is the single biggest performance win in X++ development. A row-by-row loop can take minutes; the equivalent set-based statement takes seconds.

Row-by-row vs set-based

// ❌ ROW-BY-ROW: Slow β€” one SQL statement per row
while select forUpdate salesLine
    where salesLine.SalesStatus == SalesStatus::Backorder
{
    salesLine.SalesStatus = SalesStatus::Open;
    salesLine.update();
}
// 10,000 rows = 10,000 individual UPDATE statements

// βœ… SET-BASED: Fast β€” one SQL statement for ALL rows
update_recordset salesLine
    setting SalesStatus = SalesStatus::Open
    where salesLine.SalesStatus == SalesStatus::Backorder;
// 10,000 rows = 1 UPDATE statement

The three set-based operations

Set-based operations β€” let SQL Server do the heavy lifting
OperationRow-by-row equivalentSet-based syntaxPerformance gain
insert_recordsetwhile select β†’ insert()insert_recordset destTable (field1, field2) select field1, field2 from sourceTable10-100x faster for bulk inserts
update_recordsetwhile select forUpdate β†’ update()update_recordset table setting Field = value where condition10-100x faster for bulk updates
delete_fromwhile select forUpdate β†’ delete()delete_from table where condition10-100x faster for bulk deletes

insert_recordset example

// Copy all backorder lines from SalesLine to a staging table
insert_recordset pfStagingTable (ItemId, Qty, SalesId)
    select ItemId, SalesQty, SalesId
    from salesLine
    where salesLine.SalesStatus == SalesStatus::Backorder;

When set-based falls back to row-by-row

Certain conditions force the runtime to fall back to row-by-row processing, negating the performance benefit:

ConditionWhy it breaks set-based
Table has insert() / update() overridesCustom logic in the method must run per-row
Database event handlers (onInserting, onUpdating)Event handlers fire per-row
Computed columnsCan’t be set in bulk SQL
Non-SQL operations in the loop (API calls, file I/O)Must execute sequentially
Cross-company operationsSet-based can’t span legal entities in one statement
Exam tip: skipDataMethods and skipEvents

To force set-based execution even when table methods exist:

pfStagingTable.skipDataMethods(true);  // Skip insert/update overrides
pfStagingTable.skipEvents(true);       // Skip database events

update_recordset pfStagingTable
    setting Status = PFStatus::Processed
    where pfStagingTable.Status == PFStatus::Pending;

⚠️ Use with caution β€” skipping methods and events means business logic won’t run. Only use when you’re certain the skipped logic isn’t needed (e.g. staging table population where validation already happened).

Query hints and performance

HintPurposeWhen to use
forceSelectOrderForces SQL to use the join order specified in X++Prevents optimiser from choosing a bad plan
forcePlaceholdersForces parameterised queriesAvoids parameter sniffing issues
forceLiteralsForces literal values in queriesBetter plans when parameter distribution is skewed
forceNestedLoopForces nested loop joinsWhen optimiser incorrectly chooses hash joins
firstOnlyReturns only the first recordPrevents unnecessary full-table scans
firstFastOptimises for returning the first row quicklyGood for existence checks
Scenario: Elena optimises a slow batch job

PacificForge’s nightly inventory valuation batch takes 4 hours. Elena profiles it and finds:

ProblemFixImpact
Row-by-row insert into staging tableinsert_recordset with skipDataMethods(true)45 min β†’ 2 min
Row-by-row update of valuation amountsupdate_recordset90 min β†’ 5 min
Lookup table queried per row in loopSet table to Found caching60 min β†’ 10 min
Non-indexed WHERE clause on InventTransAdded a non-clustered index45 min β†’ 8 min

Total: 4 hours β†’ 25 minutes. β€œSet-based operations are the biggest single win,” Elena tells the team.

Concurrency control

When two users (or batch threads) try to update the same record simultaneously, F&O needs a strategy to prevent data loss or corruption.

Optimistic = detect conflicts late. Pessimistic = prevent conflicts early.
AspectOptimistic ConcurrencyPessimistic Concurrency
When lock is acquiredAt save time (update/delete)At read time (select forUpdate)
Lock durationBrief β€” only during the writeLong β€” from read until commit/rollback
Conflict detectionRecVersion check β€” fails if record changed since readLock prevents others from reading/writing
User experienceUsers can work simultaneously; conflicts detected on saveSecond user waits (or gets a lock timeout error)
Best forForms and interactive scenarios (conflicts are rare)Batch jobs where conflicts must never happen
Table propertyOccEnabled = Yes (default for most tables)select pessimisticLock or select forUpdate in pessimistic mode

Optimistic concurrency in action

// Optimistic: read record, user edits, save checks RecVersion
ttsBegin;
select forUpdate salesTable
    where salesTable.SalesId == 'SO-001';
// User edits fields...
salesTable.SalesStatus = SalesStatus::Confirmed;
salesTable.update(); // Checks RecVersion β€” throws if changed
ttsCommit;

// Handle conflict
catch (Exception::UpdateConflict)
{
    // Record was modified by another user since we read it
    if (appl.ttsLevel() == 0)
    {
        if (xSession::currentRetryCount() < 3)
        {
            retry;
        }
    }
}

Pessimistic locking

// Pessimistic: lock the record at read time
ttsBegin;
select pessimisticLock salesTable
    where salesTable.SalesId == 'SO-001';
// Record is now locked β€” no other session can update it
salesTable.SalesStatus = SalesStatus::Confirmed;
salesTable.update();
ttsCommit;
// Lock released on commit
Exam tip: RecVersion and UpdateConflict

Optimistic concurrency uses the RecVersion field (auto-incremented on every update):

  1. Session A reads SalesTable where SalesId = β€˜SO-001’ (RecVersion = 5)
  2. Session B reads the same record (RecVersion = 5)
  3. Session A saves β†’ update succeeds, RecVersion becomes 6
  4. Session B saves β†’ system compares RecVersion (expected 5, actual 6) β†’ UpdateConflict exception

The exam tests: β€œTwo users edit the same record. User A saves first. What happens when User B saves?” β†’ UpdateConflict (optimistic concurrency with OccEnabled = Yes).

Variable scoping and garbage collection

Large objects (like populated temp tables, buffers, containers) should be scoped as tightly as possible to allow the garbage collector to reclaim memory:

// ❌ Bad: large buffer lives for entire method
void processAllOrders()
{
    SalesTable salesBuf; // Alive for entire method
    // ... 500 lines of code ...
}

// βœ… Good: scope buffers to where they're needed
void processAllOrders()
{
    this.loadOrders();   // salesBuf scoped inside
    this.processLines(); // separate buffer scoped inside
}

Question

What are the three table caching types in F&O, and when do you use each?

Click or press Enter to reveal answer

Answer

Found β€” caches records found by unique index lookups (good for lookup tables like CustTable). FoundAndEmpty β€” also caches 'not found' results (good for sparse config tables). EntireTable β€” loads the whole table into memory at first access (only for small, rarely-changing tables like Parameters, Units, Currencies).

Click to flip back

Question

What is the key difference between TempDB and InMemory temp tables?

Click or press Enter to reveal answer

Answer

InMemory tables live in AOS memory β€” fast for small datasets but no set-based operations or joins. TempDB tables live in SQL Server's tempdb β€” full SQL capability (joins, set-based ops, aggregations) and suitable for large datasets. Use TempDB for report RDP classes and any scenario with more than a few hundred rows.

Click to flip back

Question

Name the three set-based operations in X++ and what they replace.

Click or press Enter to reveal answer

Answer

insert_recordset (replaces while select β†’ insert loop), update_recordset (replaces while select forUpdate β†’ update loop), delete_from (replaces while select forUpdate β†’ delete loop). Each generates a single SQL statement instead of one per row β€” typically 10-100x faster.

Click to flip back

Question

What is optimistic concurrency and how does it work in F&O?

Click or press Enter to reveal answer

Answer

Optimistic concurrency detects conflicts at save time using the RecVersion field. When a record is read, its RecVersion is noted. On update, the system checks if RecVersion still matches. If another session changed it, an UpdateConflict exception is thrown. Default for most tables (OccEnabled = Yes). Good for interactive scenarios where conflicts are rare.

Click to flip back

Question

When does a set-based operation fall back to row-by-row in X++?

Click or press Enter to reveal answer

Answer

When the table has insert()/update() method overrides, database event handlers (onInserting etc.), computed columns, cross-company operations, or when the loop contains non-SQL operations (API calls, file I/O). Use skipDataMethods(true) and skipEvents(true) to force set-based when safe.

Click to flip back


Knowledge Check

Elena sets EntireTable caching on InventTrans (a table with 2 million rows that receives thousands of inserts daily). What will happen?

Knowledge Check

Vik needs to update the status of 50,000 sales lines from 'Backorder' to 'Open'. Which approach is fastest?

Knowledge Check

Two users read the same sales order (RecVersion = 12). User A saves first (RecVersion becomes 13). What happens when User B tries to save?

Knowledge Check

Elena's RDP class populates a temp table with 100,000 rows for an SSRS report. Which temp table type should she use?

Next up: Trace Parser & Optimization β€” capturing traces, analysing call trees, index strategy, and optimising batch processes.