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
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 Type | What it caches | Cache miss behaviour | Best for |
|---|---|---|---|
| Found | Records found in previous lookups | Queries the database, does NOT cache misses | Tables where records exist for most lookups (CustTable, VendTable) |
| FoundAndEmpty | Found records AND 'not found' results | Queries the database, caches the miss too | Tables where many lookups return no result (config tables with sparse data) |
| EntireTable | The entire table loaded into memory at first access | N/A β all data is already cached | Small reference tables (<500 rows) that rarely change (Units, Currencies, Parameters) |
| None | Nothing cached | Always hits the database | Large 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 well | Caching hurts |
|---|---|
| Small lookup tables (< 500 rows) | Large transactional tables (millions of rows) |
| Data changes infrequently | Data changes frequently (invalidation thrashing) |
| Lookups use the tableβs unique index | Queries use non-indexed fields or complex joins |
| Read-heavy workloads | Write-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
}
| Feature | Detail |
|---|---|
| Scope | Shared across all sessions on the same AOS instance |
| Key | Container β can be multi-part (class name + scope + key fields) |
| Value | Container β any serialisable data |
| Invalidation | Manual β you must explicitly remove or update entries when data changes |
| Lifetime | Until 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:
| Aspect | InMemory | TempDB |
|---|---|---|
| Storage location | Application server (AOS) memory | SQL Server tempdb database |
| SQL capability | Limited β no joins in select, no set-based ops | Full SQL β joins, set-based ops, aggregations all work |
| Data volume | Small datasets (< 1,000 rows) | Large datasets (thousands to millions of rows) |
| Performance profile | Fast for small data (no network round trip) | Fast for large data (SQL engine handles bulk ops) |
| Cross-tier | Cannot be passed between client and server tiers | Can be passed between tiers (serialised to SQL) |
| Set-based support | β No insert_recordset, update_recordset | β Full set-based operation support |
| SSRS reports | Supported as RDP temp table | Supported as RDP temp table (preferred for large reports) |
| Table property | TableType = InMemory | TableType = 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
| Operation | Row-by-row equivalent | Set-based syntax | Performance gain |
|---|---|---|---|
| insert_recordset | while select β insert() | insert_recordset destTable (field1, field2) select field1, field2 from sourceTable | 10-100x faster for bulk inserts |
| update_recordset | while select forUpdate β update() | update_recordset table setting Field = value where condition | 10-100x faster for bulk updates |
| delete_from | while select forUpdate β delete() | delete_from table where condition | 10-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:
| Condition | Why it breaks set-based |
|---|---|
Table has insert() / update() overrides | Custom logic in the method must run per-row |
| Database event handlers (onInserting, onUpdating) | Event handlers fire per-row |
| Computed columns | Canβt be set in bulk SQL |
| Non-SQL operations in the loop (API calls, file I/O) | Must execute sequentially |
| Cross-company operations | Set-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
| Hint | Purpose | When to use |
|---|---|---|
| forceSelectOrder | Forces SQL to use the join order specified in X++ | Prevents optimiser from choosing a bad plan |
| forcePlaceholders | Forces parameterised queries | Avoids parameter sniffing issues |
| forceLiterals | Forces literal values in queries | Better plans when parameter distribution is skewed |
| forceNestedLoop | Forces nested loop joins | When optimiser incorrectly chooses hash joins |
| firstOnly | Returns only the first record | Prevents unnecessary full-table scans |
| firstFast | Optimises for returning the first row quickly | Good for existence checks |
Scenario: Elena optimises a slow batch job
PacificForgeβs nightly inventory valuation batch takes 4 hours. Elena profiles it and finds:
| Problem | Fix | Impact |
|---|---|---|
| Row-by-row insert into staging table | insert_recordset with skipDataMethods(true) | 45 min β 2 min |
| Row-by-row update of valuation amounts | update_recordset | 90 min β 5 min |
| Lookup table queried per row in loop | Set table to Found caching | 60 min β 10 min |
| Non-indexed WHERE clause on InventTrans | Added a non-clustered index | 45 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.
| Aspect | Optimistic Concurrency | Pessimistic Concurrency |
|---|---|---|
| When lock is acquired | At save time (update/delete) | At read time (select forUpdate) |
| Lock duration | Brief β only during the write | Long β from read until commit/rollback |
| Conflict detection | RecVersion check β fails if record changed since read | Lock prevents others from reading/writing |
| User experience | Users can work simultaneously; conflicts detected on save | Second user waits (or gets a lock timeout error) |
| Best for | Forms and interactive scenarios (conflicts are rare) | Batch jobs where conflicts must never happen |
| Table property | OccEnabled = 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):
- Session A reads SalesTable where SalesId = βSO-001β (RecVersion = 5)
- Session B reads the same record (RecVersion = 5)
- Session A saves β update succeeds, RecVersion becomes 6
- 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
}
Elena sets EntireTable caching on InventTrans (a table with 2 million rows that receives thousands of inserts daily). What will happen?
Vik needs to update the status of 50,000 sales lines from 'Backorder' to 'Open'. Which approach is fastest?
Two users read the same sales order (RecVersion = 12). User A saves first (RecVersion becomes 13). What happens when User B tries to save?
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.