mirror of
https://github.com/microsoft/BCQuality.git
synced 2026-08-06 09:26:52 +01:00
Regenerate microsoft/knowledge from upstream BCApps instructions
The previous LLM-generated knowledge files contained factual
hallucinations. The most visible was the claim that `FindFirst` /
`FindLast` "forces a full-table scan" on an unfiltered record - it does
not; those APIs return a single row via the current key.
Other inaccuracies the audit found and fixed:
* `FindSet(true)` was described as "taking a LockTable". The correct
upstream phrasing is that `FindSet(true)` sets
`ReadIsolation::UpdLock` on the read. UpdLock and LockTable are
related but distinct mechanisms.
* The list of production-scale tables had been invented beyond the
upstream source (e.g. "Detailed Cust. Ledg. Entry") without a
citation. The regenerated list matches the ten tables upstream lists
with their P95 row counts.
* `SetLoadFields` guidance had been augmented with an extra mechanism
claim ("the database resolves the filter using the index without
hydrating the value") not present in upstream.
Approach: full regeneration of `microsoft/knowledge/` from the six
upstream BCApps Code Review instruction files, with Microsoft Learn /
the AL language reference as a secondary source. Every claim in every
regenerated file is anchored to a verbatim upstream quote (or a Learn
URL); the audit trail lives in artifacts/trace-<domain>.json on the
session workspace.
The PR #11 transaction/error-handling cluster is preserved verbatim:
* performance/understand-implicit-transaction-boundary.md
* performance/codeunit-run-as-atomic-sub-operation.{md,good.al,bad.al}
* performance/codeunit-run-requires-prior-commit-inside-transaction.{md,good.al,bad.al}
* performance/use-tryfunction-for-error-catching-not-rollback.{md,good.al,bad.al}
* performance/avoid-commit-inside-loops.{md,good.al,bad.al}
* security/commitbehavior-attribute-scopes-explicit-commits.{md,good.al,bad.al}
* testing/transactionmodel-attribute-governs-test-transactions.{md,good.al,bad.al}
These articles already cite Microsoft Learn and were carefully
cross-referenced; the regeneration skips their topics rather than
duplicating them.
File counts after regeneration:
performance 35 .md (5 preserved + 30 new)
privacy 17 .md
security 18 .md (1 preserved + 17 new)
style 33 .md
testing 1 .md (preserved)
ui 19 .md
upgrade 18 .md
Total 141 atomic knowledge files, each strictly one rule. All pass
.github/scripts/validate_frontmatter.py with 0 errors and 0 warnings.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
parent
613c4b4019
commit
a9f3c50863
562 changed files with 6293 additions and 4869 deletions
|
|
@ -1,10 +0,0 @@
|
||||||
tableextension 50118 "Perf Sample SIFTKey" extends "Cust. Ledger Entry"
|
|
||||||
{
|
|
||||||
keys
|
|
||||||
{
|
|
||||||
key(PerfSampleOpenByCustomer; "Customer No.", Open, "Posting Date")
|
|
||||||
{
|
|
||||||
SumIndexFields = "Remaining Amt. (LCY)";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,25 +0,0 @@
|
||||||
---
|
|
||||||
bc-version: [all]
|
|
||||||
domain: performance
|
|
||||||
keywords: [sift, sumindexfields, flowfield, key, aa0232]
|
|
||||||
technologies: [al]
|
|
||||||
countries: [w1]
|
|
||||||
application-area: [all]
|
|
||||||
---
|
|
||||||
|
|
||||||
# Add SIFT keys for FlowField aggregations
|
|
||||||
|
|
||||||
## Description
|
|
||||||
|
|
||||||
CodeCop rule AA0232 checks that FlowFields backed by CalcSums or aggregation CalcFormula are supported by a key whose SumIndexFields include the summed field and whose key prefix matches the formula's filter fields. Without a SIFT key the platform falls back to a full aggregation on every read — typically invisible in development and catastrophic in production.
|
|
||||||
|
|
||||||
## Best Practice
|
|
||||||
|
|
||||||
For each Sum-style FlowField, ensure the source table has a key whose leading fields match the FlowField's CalcFormula WHERE clause and whose SumIndexFields list includes the summed field. Table extensions adding new FlowFields are responsible for adding the supporting key.
|
|
||||||
|
|
||||||
See sample: `add-sift-keys-for-flowfields.good.al`.
|
|
||||||
|
|
||||||
## Anti Pattern
|
|
||||||
|
|
||||||
Declaring a FlowField on a hot table without checking whether a supporting SIFT key exists ships a latent scan into every list page and report that touches the field.
|
|
||||||
|
|
||||||
|
|
@ -0,0 +1,14 @@
|
||||||
|
report 50221 "Perf Sample AddLoadFields Bad"
|
||||||
|
{
|
||||||
|
dataset
|
||||||
|
{
|
||||||
|
// No AddLoadFields: every Cust. Ledger Entry column ships per row, even though
|
||||||
|
// only three columns feed the layout.
|
||||||
|
dataitem(CustLedgerEntry; "Cust. Ledger Entry")
|
||||||
|
{
|
||||||
|
column(CustomerNo; "Customer No.") { }
|
||||||
|
column(PostingDate; "Posting Date") { }
|
||||||
|
column(Amount; Amount) { }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,8 +1,8 @@
|
||||||
report 50112 "Perf Sample AddLoadFields Good"
|
report 50220 "Perf Sample AddLoadFields Good"
|
||||||
{
|
{
|
||||||
dataset
|
dataset
|
||||||
{
|
{
|
||||||
dataitem(Cust; "Cust. Ledger Entry")
|
dataitem(CustLedgerEntry; "Cust. Ledger Entry")
|
||||||
{
|
{
|
||||||
column(CustomerNo; "Customer No.") { }
|
column(CustomerNo; "Customer No.") { }
|
||||||
column(PostingDate; "Posting Date") { }
|
column(PostingDate; "Posting Date") { }
|
||||||
|
|
@ -0,0 +1,26 @@
|
||||||
|
---
|
||||||
|
bc-version: [all]
|
||||||
|
domain: performance
|
||||||
|
keywords: [report, addloadfields, onpredataitem, dataitem, partial-record, layout]
|
||||||
|
technologies: [al]
|
||||||
|
countries: [w1]
|
||||||
|
application-area: [all]
|
||||||
|
---
|
||||||
|
|
||||||
|
# In reports, declare the fields the layout needs with AddLoadFields
|
||||||
|
|
||||||
|
## Description
|
||||||
|
|
||||||
|
Reports iterate dataitems on potentially large source tables and pipe rows into a layout. The partial-record optimization is the same idea as `use-setloadfields-for-partial-records.md`, but the API is different: per the upstream guidance, "for reports, use `AddLoadFields()` in `OnPreDataItem` trigger to add fields needed by the layout." `AddLoadFields` is additive — call it for each field the layout consumes — and runs once per dataitem before iteration begins.
|
||||||
|
|
||||||
|
## Best Practice
|
||||||
|
|
||||||
|
In each dataitem's `OnPreDataItem` trigger, list the columns the layout binds to via `AddLoadFields(<field>, <field>, ...)`. The platform then materializes only those columns per row. Treat the layout column list as the spec: every column the layout uses must be added; columns the layout does not use should not be added.
|
||||||
|
|
||||||
|
See sample: `addloadfields-in-report-onpredataitem.good.al`.
|
||||||
|
|
||||||
|
## Anti Pattern
|
||||||
|
|
||||||
|
Relying on the dataitem's default to load every field. On a report bound to a ledger-scale table this transfers an entire row per iteration, of which the layout reads a fraction.
|
||||||
|
|
||||||
|
See sample: `addloadfields-in-report-onpredataitem.bad.al`.
|
||||||
|
|
@ -0,0 +1,22 @@
|
||||||
|
---
|
||||||
|
bc-version: [all]
|
||||||
|
domain: performance
|
||||||
|
keywords: [admin-page, migration, wizard, hybrid, permissions, lower-severity]
|
||||||
|
technologies: [al]
|
||||||
|
countries: [w1]
|
||||||
|
application-area: [all]
|
||||||
|
---
|
||||||
|
|
||||||
|
# Admin and migration pages tolerate lower performance discipline
|
||||||
|
|
||||||
|
## Description
|
||||||
|
|
||||||
|
Some pages run rarely and against small datasets, and the upstream guidance explicitly calls for treating them as lower severity. Per the review checklist, "Admin/migration pages (`Admin`, `Setup`, `Wizard`, `Migration`, `HybridBC14`, `HybridSL`, `HybridGP` namespaces, `Permissions`/`PermissionSet` pages) are infrequently used with small datasets — apply lower severity." The same logic covers one-time wizards and tenant-bootstrap routines: the code path runs a handful of times in the lifetime of a tenant, against a bounded dataset, by an administrator.
|
||||||
|
|
||||||
|
## Best Practice
|
||||||
|
|
||||||
|
When triaging a finding on an admin, migration, or wizard page, downgrade severity relative to the same finding on a hot business path. A `FindSet` loop without `SetLoadFields` on a migration page that processes setup records once per tenant is a different finding than the same loop on a posting routine that runs thousands of times a day. Note this context explicitly in the review so the call site is not "fixed" twice with diminishing returns.
|
||||||
|
|
||||||
|
## Anti Pattern
|
||||||
|
|
||||||
|
Treating a migration wizard's per-row loop with the same urgency as the same loop in `Sales-Post`. The fix cost is the same; the production benefit is not. Bulk-rewriting an admin page to use `ModifyAll` and partial records buys nothing the user will perceive.
|
||||||
|
|
@ -1,7 +1,10 @@
|
||||||
codeunit 50101 "Perf Sample FilterBeforeFind Bad"
|
codeunit 50229 "Perf Sample FilterEarly Bad"
|
||||||
{
|
{
|
||||||
procedure ProcessUsCustomers(var Customer: Record Customer)
|
procedure ProcessUSCustomers()
|
||||||
|
var
|
||||||
|
Customer: Record Customer;
|
||||||
begin
|
begin
|
||||||
|
// Reads every customer in the table, discards the non-US ones in AL.
|
||||||
if Customer.FindSet() then
|
if Customer.FindSet() then
|
||||||
repeat
|
repeat
|
||||||
if Customer."Country/Region Code" = 'US' then
|
if Customer."Country/Region Code" = 'US' then
|
||||||
|
|
@ -11,6 +14,5 @@ codeunit 50101 "Perf Sample FilterBeforeFind Bad"
|
||||||
|
|
||||||
local procedure ProcessCustomer(var Customer: Record Customer)
|
local procedure ProcessCustomer(var Customer: Record Customer)
|
||||||
begin
|
begin
|
||||||
// per-customer work
|
|
||||||
end;
|
end;
|
||||||
}
|
}
|
||||||
|
|
@ -1,6 +1,8 @@
|
||||||
codeunit 50100 "Perf Sample FilterBeforeFind Good"
|
codeunit 50228 "Perf Sample FilterEarly Good"
|
||||||
{
|
{
|
||||||
procedure ProcessUsCustomers(var Customer: Record Customer)
|
procedure ProcessUSCustomers()
|
||||||
|
var
|
||||||
|
Customer: Record Customer;
|
||||||
begin
|
begin
|
||||||
Customer.SetRange("Country/Region Code", 'US');
|
Customer.SetRange("Country/Region Code", 'US');
|
||||||
if Customer.FindSet() then
|
if Customer.FindSet() then
|
||||||
|
|
@ -11,6 +13,5 @@ codeunit 50100 "Perf Sample FilterBeforeFind Good"
|
||||||
|
|
||||||
local procedure ProcessCustomer(var Customer: Record Customer)
|
local procedure ProcessCustomer(var Customer: Record Customer)
|
||||||
begin
|
begin
|
||||||
// per-customer work
|
|
||||||
end;
|
end;
|
||||||
}
|
}
|
||||||
|
|
@ -0,0 +1,26 @@
|
||||||
|
---
|
||||||
|
bc-version: [all]
|
||||||
|
domain: performance
|
||||||
|
keywords: [setrange, setfilter, filter, loop, early, dataset]
|
||||||
|
technologies: [al]
|
||||||
|
countries: [w1]
|
||||||
|
application-area: [all]
|
||||||
|
---
|
||||||
|
|
||||||
|
# Apply SetRange/SetFilter before iterating, not as an if-test inside the loop
|
||||||
|
|
||||||
|
## Description
|
||||||
|
|
||||||
|
A `SetRange` or `SetFilter` placed before `FindSet` narrows the result set at the database. The same condition expressed as an `if` inside the loop body filters in AL, after every row has crossed the boundary. Per the upstream guidance, "apply `SetRange`/`SetFilter` as early as possible to reduce dataset" and "more specific filters = better performance." On a production-scale table the difference is the difference between scanning a subset and scanning the whole table.
|
||||||
|
|
||||||
|
## Best Practice
|
||||||
|
|
||||||
|
Move every predicate that can be expressed as an equality or range filter into a `SetRange` or `SetFilter` ahead of the find. Combine with `SetCurrentKey` to choose a key whose first fields match the filter (see `setcurrentkey-aligns-key-with-filters.md`). The loop body should then contain only the work that depends on per-row state.
|
||||||
|
|
||||||
|
See sample: `apply-filters-before-iterating.good.al`.
|
||||||
|
|
||||||
|
## Anti Pattern
|
||||||
|
|
||||||
|
`if Customer.FindSet() then repeat if Customer."Country/Region Code" = 'US' then ProcessCustomer(Customer); until Customer.Next() = 0;` — the loop pays for every row in the table and discards the non-matching ones in AL. The intent is the same as a `SetRange("Country/Region Code", 'US')` ahead of the find, but the cost is not.
|
||||||
|
|
||||||
|
See sample: `apply-filters-before-iterating.bad.al`.
|
||||||
|
|
@ -0,0 +1,14 @@
|
||||||
|
codeunit 50215 "Perf Sample GuardBeforeGet Bad"
|
||||||
|
{
|
||||||
|
procedure ResolveAllocation(var PurchaseLine: Record "Purchase Line")
|
||||||
|
var
|
||||||
|
PurchaseHeader: Record "Purchase Header";
|
||||||
|
begin
|
||||||
|
// Wasted lookup when the line has no allocation account: the procedure
|
||||||
|
// exits below, but the header was already fetched.
|
||||||
|
PurchaseHeader.Get(PurchaseLine."Document Type", PurchaseLine."Document No.");
|
||||||
|
if PurchaseLine."Selected Alloc. Account No." = '' then
|
||||||
|
exit;
|
||||||
|
// ...
|
||||||
|
end;
|
||||||
|
}
|
||||||
|
|
@ -1,15 +1,12 @@
|
||||||
codeunit 51201 "Perf Sample GuardBeforeGet Bad"
|
codeunit 50214 "Perf Sample GuardBeforeGet Good"
|
||||||
{
|
{
|
||||||
procedure HandleLine(var PurchaseLine: Record "Purchase Line")
|
procedure ResolveAllocation(var PurchaseLine: Record "Purchase Line")
|
||||||
var
|
var
|
||||||
PurchaseHeader: Record "Purchase Header";
|
PurchaseHeader: Record "Purchase Header";
|
||||||
begin
|
begin
|
||||||
// Get fires on every call — including the ones that exit immediately below.
|
|
||||||
PurchaseHeader.Get(PurchaseLine."Document Type", PurchaseLine."Document No.");
|
|
||||||
|
|
||||||
if PurchaseLine."Selected Alloc. Account No." = '' then
|
if PurchaseLine."Selected Alloc. Account No." = '' then
|
||||||
exit;
|
exit;
|
||||||
|
PurchaseHeader.Get(PurchaseLine."Document Type", PurchaseLine."Document No.");
|
||||||
// Work with PurchaseHeader.
|
// ...
|
||||||
end;
|
end;
|
||||||
}
|
}
|
||||||
26
microsoft/knowledge/performance/apply-guards-before-get.md
Normal file
26
microsoft/knowledge/performance/apply-guards-before-get.md
Normal file
|
|
@ -0,0 +1,26 @@
|
||||||
|
---
|
||||||
|
bc-version: [all]
|
||||||
|
domain: performance
|
||||||
|
keywords: [get, guard, early-exit, conditional, lookup, wasted-query]
|
||||||
|
technologies: [al]
|
||||||
|
countries: [w1]
|
||||||
|
application-area: [all]
|
||||||
|
---
|
||||||
|
|
||||||
|
# Apply early-exit guards before calling Get
|
||||||
|
|
||||||
|
## Description
|
||||||
|
|
||||||
|
A `Get` (or any other database call) executed before a guard that may exit the procedure does a round-trip the procedure never uses. Per the upstream guidance, "Flag `Get()` calls that execute before a guard condition that may exit early — the DB lookup is wasted." The fix is structural: order the procedure body so cheap checks (parameter validation, in-memory field comparisons, enum tests) run first, and the database call runs only after the guards pass.
|
||||||
|
|
||||||
|
## Best Practice
|
||||||
|
|
||||||
|
Read the procedure top-to-bottom and place every condition that can short-circuit ahead of every database call. The check `if SomeNo = '' then exit;` belongs above `Header.Get(...)`, not below. Each guard moved upward saves one wasted query on the path that exits.
|
||||||
|
|
||||||
|
See sample: `apply-guards-before-get.good.al`.
|
||||||
|
|
||||||
|
## Anti Pattern
|
||||||
|
|
||||||
|
`Record.Get(...)` at the top of a procedure followed by `if SomeField = '' then exit;`. The code reads top-down as "load the record, then decide whether we needed it" — exactly the order that wastes the query. The pattern is easy to introduce when guards are added later, defensively, without re-checking call ordering.
|
||||||
|
|
||||||
|
See sample: `apply-guards-before-get.bad.al`.
|
||||||
|
|
@ -1,18 +0,0 @@
|
||||||
codeunit 50117 "Perf Sample CalcFieldsInLoop Bad"
|
|
||||||
{
|
|
||||||
procedure ProcessLargeLines(var SalesHeader: Record "Sales Header"; var SalesLine: Record "Sales Line")
|
|
||||||
begin
|
|
||||||
SalesLine.SetRange("Document Type", SalesHeader."Document Type");
|
|
||||||
SalesLine.SetRange("Document No.", SalesHeader."No.");
|
|
||||||
if SalesLine.FindSet() then
|
|
||||||
repeat
|
|
||||||
SalesHeader.CalcFields(Amount);
|
|
||||||
if SalesHeader.Amount > 1000 then
|
|
||||||
ProcessLine(SalesLine);
|
|
||||||
until SalesLine.Next() = 0;
|
|
||||||
end;
|
|
||||||
|
|
||||||
local procedure ProcessLine(var SalesLine: Record "Sales Line")
|
|
||||||
begin
|
|
||||||
end;
|
|
||||||
}
|
|
||||||
|
|
@ -1,18 +0,0 @@
|
||||||
codeunit 50116 "Perf Sample CalcFieldsInLoop Good"
|
|
||||||
{
|
|
||||||
procedure ProcessLargeLines(var SalesHeader: Record "Sales Header"; var SalesLine: Record "Sales Line")
|
|
||||||
begin
|
|
||||||
SalesHeader.CalcFields(Amount);
|
|
||||||
SalesLine.SetRange("Document Type", SalesHeader."Document Type");
|
|
||||||
SalesLine.SetRange("Document No.", SalesHeader."No.");
|
|
||||||
if SalesLine.FindSet() then
|
|
||||||
repeat
|
|
||||||
if SalesHeader.Amount > 1000 then
|
|
||||||
ProcessLine(SalesLine);
|
|
||||||
until SalesLine.Next() = 0;
|
|
||||||
end;
|
|
||||||
|
|
||||||
local procedure ProcessLine(var SalesLine: Record "Sales Line")
|
|
||||||
begin
|
|
||||||
end;
|
|
||||||
}
|
|
||||||
|
|
@ -1,29 +0,0 @@
|
||||||
---
|
|
||||||
bc-version: [all]
|
|
||||||
domain: performance
|
|
||||||
keywords: [calcfields, flowfield, loop, n-plus-one]
|
|
||||||
technologies: [al]
|
|
||||||
countries: [w1]
|
|
||||||
application-area: [all]
|
|
||||||
---
|
|
||||||
|
|
||||||
# Do not call CalcFields inside loops
|
|
||||||
|
|
||||||
## Description
|
|
||||||
|
|
||||||
CalcFields evaluates one or more FlowFields for the current record by issuing a separate SQL aggregation. Called inside a loop over a record set, it becomes an N+1 problem: one aggregate per row. For any non-trivial set on a ledger-entry-backed FlowField this is orders of magnitude slower than the equivalent batched query.
|
|
||||||
|
|
||||||
## Best Practice
|
|
||||||
|
|
||||||
Move CalcFields out of the iteration. If the total is what you need, use CalcSums on the filtered parent set. If row-by-row FlowField values are needed, reshape the computation so the aggregate runs once — for example by joining against a temporary table populated in a single batched query.
|
|
||||||
|
|
||||||
**Acceptable exceptions:** CalcFields inside an `OnAfterGetRecord` page trigger is the standard pattern for displaying computed FlowField values — the platform calls this trigger once per row and it is not a developer-authored loop. Similarly, CalcFields inside an `OnValidate` field trigger fires at most once per user action and is acceptable. The concern is only developer-written `FindSet … repeat … until Next() = 0` loops.
|
|
||||||
|
|
||||||
See sample: `avoid-calcfields-in-loops.good.al`.
|
|
||||||
|
|
||||||
## Anti Pattern
|
|
||||||
|
|
||||||
Calling CalcFields inside `repeat ... until Next() = 0` on a hot parent record is the textbook N+1 pattern. Even a modest parent set size (hundreds of rows) turns into thousands of round-trips.
|
|
||||||
|
|
||||||
See sample: `avoid-calcfields-in-loops.bad.al`.
|
|
||||||
|
|
||||||
|
|
@ -1,14 +0,0 @@
|
||||||
codeunit 50105 "Perf Sample AvoidFindFirstNext Bad"
|
|
||||||
{
|
|
||||||
procedure EmitAllItems(var Item: Record Item)
|
|
||||||
begin
|
|
||||||
if Item.FindFirst() then
|
|
||||||
repeat
|
|
||||||
EmitItem(Item);
|
|
||||||
until Item.Next() = 0;
|
|
||||||
end;
|
|
||||||
|
|
||||||
local procedure EmitItem(var Item: Record Item)
|
|
||||||
begin
|
|
||||||
end;
|
|
||||||
}
|
|
||||||
|
|
@ -1,25 +0,0 @@
|
||||||
---
|
|
||||||
bc-version: [all]
|
|
||||||
domain: performance
|
|
||||||
keywords: [findfirst, findlast, get, next, aa0233]
|
|
||||||
technologies: [al]
|
|
||||||
countries: [w1]
|
|
||||||
application-area: [all]
|
|
||||||
---
|
|
||||||
|
|
||||||
# Do not pair FindFirst, FindLast, or Get with Next
|
|
||||||
|
|
||||||
## Description
|
|
||||||
|
|
||||||
CodeCop rule AA0233 flags loops that start with FindFirst, FindLast, or Get and then call Next. FindFirst and FindLast retrieve a single row and reposition the cursor; calling Next after them forces the platform to re-seek and stream the rest of the set, which is slower than the correct FindSet pattern and signals intent incorrectly to reviewers and the optimizer.
|
|
||||||
|
|
||||||
## Best Practice
|
|
||||||
|
|
||||||
Choose the Find variant that matches the operation: FindSet for full iteration, FindFirst or FindLast when you want exactly one row, Get when the primary key is known. Never call Next after FindFirst, FindLast, or Get.
|
|
||||||
|
|
||||||
## Anti Pattern
|
|
||||||
|
|
||||||
Writing `if Rec.FindFirst() then repeat ... until Rec.Next() = 0` is the canonical AA0233 offender. The loop wastes bandwidth and obscures the author's intent.
|
|
||||||
|
|
||||||
See sample: `avoid-findfirst-with-next.bad.al`.
|
|
||||||
|
|
||||||
|
|
@ -0,0 +1,15 @@
|
||||||
|
codeunit 50253 "Perf Sample NPlus1 Bad"
|
||||||
|
{
|
||||||
|
procedure SumStdCost(var BOMLine: Record "BOM Component") TotalCost: Decimal
|
||||||
|
var
|
||||||
|
Item: Record Item;
|
||||||
|
begin
|
||||||
|
if BOMLine.FindSet() then
|
||||||
|
repeat
|
||||||
|
// Full-row Item.Get per BOM line — no partial loading, no caching.
|
||||||
|
Item.Get(BOMLine."No.");
|
||||||
|
if Item."Costing Method" = Item."Costing Method"::Standard then
|
||||||
|
TotalCost += Item."Standard Cost" * BOMLine."Quantity per";
|
||||||
|
until BOMLine.Next() = 0;
|
||||||
|
end;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,15 @@
|
||||||
|
codeunit 50252 "Perf Sample NPlus1 Good"
|
||||||
|
{
|
||||||
|
procedure SumStdCost(var BOMLine: Record "BOM Component") TotalCost: Decimal
|
||||||
|
var
|
||||||
|
Item: Record Item;
|
||||||
|
begin
|
||||||
|
Item.SetLoadFields("Costing Method", "Standard Cost");
|
||||||
|
if BOMLine.FindSet() then
|
||||||
|
repeat
|
||||||
|
if Item.Get(BOMLine."No.") then
|
||||||
|
if Item."Costing Method" = Item."Costing Method"::Standard then
|
||||||
|
TotalCost += Item."Standard Cost" * BOMLine."Quantity per";
|
||||||
|
until BOMLine.Next() = 0;
|
||||||
|
end;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,26 @@
|
||||||
|
---
|
||||||
|
bc-version: [all]
|
||||||
|
domain: performance
|
||||||
|
keywords: [n-plus-one, get, findfirst, loop, inner-lookup, large-table]
|
||||||
|
technologies: [al]
|
||||||
|
countries: [w1]
|
||||||
|
application-area: [all]
|
||||||
|
---
|
||||||
|
|
||||||
|
# Avoid Get / FindFirst inside a loop on a large inner table
|
||||||
|
|
||||||
|
## Description
|
||||||
|
|
||||||
|
A `Get` or `FindFirst` against a different record inside a loop body produces one database round-trip per iteration — the classic N+1 pattern. Per the upstream guidance, "Flag when a `Get()`/`FindFirst()` is called inside a loop for each record — this creates N+1 database round-trips." The cost only matters when the inner table is meaningful: lookups against temporary tables, singleton setup tables, enum-mapping tables, permission objects, or Role IDs are bounded and safe. The pattern to catch is the inner lookup that hits a production-scale table for every outer row.
|
||||||
|
|
||||||
|
## Best Practice
|
||||||
|
|
||||||
|
When the loop needs values from another record, lift the lookup out of the loop if the rows can be collected up front, or apply `SetLoadFields` so each inner read transfers only the columns the loop actually uses (see `use-setloadfields-for-partial-records.md`). When the inner record is small or bounded, leave the call site alone — the rule targets large-table inner lookups specifically.
|
||||||
|
|
||||||
|
See sample: `avoid-get-inside-loop-on-large-table.good.al`.
|
||||||
|
|
||||||
|
## Anti Pattern
|
||||||
|
|
||||||
|
Iterating BOM lines and calling `Item.Get(BOMLine."No.")` per row to read a costing method, with no `SetLoadFields` on `Item`. Each iteration issues one query against Item (~800k rows) and pulls the entire row to read two fields. The fix is `Item.SetLoadFields("Costing Method", "Standard Cost");` ahead of the loop — still N reads, but each one transfers only the needed columns.
|
||||||
|
|
||||||
|
See sample: `avoid-get-inside-loop-on-large-table.bad.al`.
|
||||||
|
|
@ -0,0 +1,22 @@
|
||||||
|
codeunit 50255 "Perf Sample RecRef Bad"
|
||||||
|
{
|
||||||
|
procedure ProcessAllCustomerNames()
|
||||||
|
var
|
||||||
|
Customer: Record Customer;
|
||||||
|
RecRef: RecordRef;
|
||||||
|
FldRef: FieldRef;
|
||||||
|
begin
|
||||||
|
RecRef.Open(Database::Customer);
|
||||||
|
if RecRef.FindSet() then
|
||||||
|
repeat
|
||||||
|
// Table and field are fixed at compile time, but every iteration
|
||||||
|
// pays dynamic resolution cost.
|
||||||
|
FldRef := RecRef.Field(Customer.FieldNo(Name));
|
||||||
|
ProcessName(Format(FldRef.Value));
|
||||||
|
until RecRef.Next() = 0;
|
||||||
|
end;
|
||||||
|
|
||||||
|
local procedure ProcessName(Name: Text)
|
||||||
|
begin
|
||||||
|
end;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,16 @@
|
||||||
|
codeunit 50254 "Perf Sample RecRef Good"
|
||||||
|
{
|
||||||
|
procedure ProcessAllCustomerNames()
|
||||||
|
var
|
||||||
|
Customer: Record Customer;
|
||||||
|
begin
|
||||||
|
if Customer.FindSet() then
|
||||||
|
repeat
|
||||||
|
ProcessName(Customer.Name);
|
||||||
|
until Customer.Next() = 0;
|
||||||
|
end;
|
||||||
|
|
||||||
|
local procedure ProcessName(Name: Text[100])
|
||||||
|
begin
|
||||||
|
end;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,26 @@
|
||||||
|
---
|
||||||
|
bc-version: [all]
|
||||||
|
domain: performance
|
||||||
|
keywords: [recordref, fieldref, hot-loop, typed-record, metadata]
|
||||||
|
technologies: [al]
|
||||||
|
countries: [w1]
|
||||||
|
application-area: [all]
|
||||||
|
---
|
||||||
|
|
||||||
|
# Avoid RecordRef / FieldRef in hot loops when a typed record fits
|
||||||
|
|
||||||
|
## Description
|
||||||
|
|
||||||
|
`RecordRef` and `FieldRef` are slower than direct typed record access — the platform resolves the table and field at runtime instead of at compile time. The trade-off is intentional: per the upstream guidance, "RecordRef/FieldRef operations are slower than direct record access, but many features REQUIRE them for generic metadata iteration (permission checks, field copying, dynamic field access)." The rule, then, is not "never use them" but "only flag when used inside a clearly unbounded hot loop (10k+ iterations) where a typed alternative exists."
|
||||||
|
|
||||||
|
## Best Practice
|
||||||
|
|
||||||
|
Use `RecordRef`/`FieldRef` for genuinely generic code — permission checks, field copying, table-agnostic export. When the loop target is known at compile time and the loop iterates a large number of rows, declare the typed record and access fields directly; the saved per-iteration overhead is measurable at the volumes the rule targets.
|
||||||
|
|
||||||
|
See sample: `avoid-recordref-in-hot-loop.good.al`.
|
||||||
|
|
||||||
|
## Anti Pattern
|
||||||
|
|
||||||
|
`RecRef.Open(Database::Customer); if RecRef.FindSet() then repeat FldRef := RecRef.Field(Customer.FieldNo(Name)); ProcessName(FldRef.Value); until RecRef.Next() = 0;` — the table is fixed at compile time, the field is fixed at compile time, and the loop pays the dynamic-resolution cost on every iteration. The direct `Customer.Name` form does the same work without the lookup.
|
||||||
|
|
||||||
|
See sample: `avoid-recordref-in-hot-loop.bad.al`.
|
||||||
|
|
@ -0,0 +1,21 @@
|
||||||
|
page 50217 "Perf Sample Redundant Bad"
|
||||||
|
{
|
||||||
|
PageType = ListPart;
|
||||||
|
SourceTable = "Assembly Line";
|
||||||
|
|
||||||
|
var
|
||||||
|
AssemblyLineRec: Record "Assembly Line";
|
||||||
|
ShowWarning: Boolean;
|
||||||
|
|
||||||
|
trigger OnAfterGetRecord()
|
||||||
|
begin
|
||||||
|
// Redundant: the platform already fetched the row into Rec.
|
||||||
|
AssemblyLineRec.Get("Document Type", "Document No.", "Line No.");
|
||||||
|
ShowWarning := CheckAvailability(AssemblyLineRec);
|
||||||
|
end;
|
||||||
|
|
||||||
|
local procedure CheckAvailability(var AssemblyLine: Record "Assembly Line"): Boolean
|
||||||
|
begin
|
||||||
|
exit(AssemblyLine.Quantity > 0);
|
||||||
|
end;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,18 @@
|
||||||
|
page 50216 "Perf Sample Redundant Good"
|
||||||
|
{
|
||||||
|
PageType = ListPart;
|
||||||
|
SourceTable = "Assembly Line";
|
||||||
|
|
||||||
|
var
|
||||||
|
ShowWarning: Boolean;
|
||||||
|
|
||||||
|
trigger OnAfterGetRecord()
|
||||||
|
begin
|
||||||
|
ShowWarning := CheckAvailability(Rec);
|
||||||
|
end;
|
||||||
|
|
||||||
|
local procedure CheckAvailability(var AssemblyLine: Record "Assembly Line"): Boolean
|
||||||
|
begin
|
||||||
|
exit(AssemblyLine.Quantity > 0);
|
||||||
|
end;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,26 @@
|
||||||
|
---
|
||||||
|
bc-version: [all]
|
||||||
|
domain: performance
|
||||||
|
keywords: [get, onaftergetrecord, redundant, page-trigger, rec, already-loaded]
|
||||||
|
technologies: [al]
|
||||||
|
countries: [w1]
|
||||||
|
application-area: [all]
|
||||||
|
---
|
||||||
|
|
||||||
|
# Do not Get the record the page already loaded
|
||||||
|
|
||||||
|
## Description
|
||||||
|
|
||||||
|
A list or card page's `OnAfterGetRecord` trigger fires *because* the platform has already fetched a row into `Rec`. Calling `Get` for that same row inside the trigger repeats the read the platform just did. Per the upstream guidance, this is "redundant — record already fetched by page runtime"; the correction is "use `Rec` directly — already loaded." The waste compounds on list pages, where the trigger runs once per row displayed.
|
||||||
|
|
||||||
|
## Best Practice
|
||||||
|
|
||||||
|
Inside page triggers — `OnAfterGetRecord`, `OnAfterGetCurrRecord`, validation triggers — read from `Rec` (or the trigger's record parameter). The platform exposes the freshly loaded record there for exactly this purpose. Reach for `Get` only when the trigger needs a *different* record than the one being displayed.
|
||||||
|
|
||||||
|
See sample: `avoid-redundant-get-when-record-already-loaded.good.al`.
|
||||||
|
|
||||||
|
## Anti Pattern
|
||||||
|
|
||||||
|
`AssemblyLineRec.Get("Document Type", "Document No.", "Line No.");` at the top of `OnAfterGetRecord`, when the trigger is on the `Assembly Line` page itself and `Rec` already holds that row. The pattern often appears when a helper that expects a record parameter is invoked from a page trigger and the author writes a `Get` to "freshen" `Rec` rather than passing `Rec` through.
|
||||||
|
|
||||||
|
See sample: `avoid-redundant-get-when-record-already-loaded.bad.al`.
|
||||||
|
|
@ -1,11 +0,0 @@
|
||||||
codeunit 50127 "Perf Sample UserInTxn Bad"
|
|
||||||
{
|
|
||||||
procedure ArchiveSalesHeader(var SalesHeader: Record "Sales Header")
|
|
||||||
begin
|
|
||||||
SalesHeader.Status := SalesHeader.Status::Released;
|
|
||||||
SalesHeader.Modify();
|
|
||||||
if not Confirm('Archive document %1?', false, SalesHeader."No.") then
|
|
||||||
exit;
|
|
||||||
SalesHeader.Delete(true);
|
|
||||||
end;
|
|
||||||
}
|
|
||||||
|
|
@ -1,14 +0,0 @@
|
||||||
codeunit 50126 "Perf Sample UserInTxn Good"
|
|
||||||
{
|
|
||||||
procedure ArchiveSalesHeader(var SalesHeader: Record "Sales Header")
|
|
||||||
begin
|
|
||||||
if not Confirm('Archive document %1?', false, SalesHeader."No.") then
|
|
||||||
exit;
|
|
||||||
DoArchive(SalesHeader);
|
|
||||||
end;
|
|
||||||
|
|
||||||
local procedure DoArchive(var SalesHeader: Record "Sales Header")
|
|
||||||
begin
|
|
||||||
// only Insert/Modify/Delete calls happen here; no prompts
|
|
||||||
end;
|
|
||||||
}
|
|
||||||
|
|
@ -1,29 +0,0 @@
|
||||||
---
|
|
||||||
bc-version: [all]
|
|
||||||
domain: performance
|
|
||||||
keywords: [confirm, strmenu, message, transaction, dialog]
|
|
||||||
technologies: [al]
|
|
||||||
countries: [w1]
|
|
||||||
application-area: [all]
|
|
||||||
---
|
|
||||||
|
|
||||||
# Do not prompt the user inside a write transaction
|
|
||||||
|
|
||||||
> Contributions welcome — open a PR to refine or extend this article.
|
|
||||||
|
|
||||||
## Description
|
|
||||||
|
|
||||||
Confirm, StrMenu, Message, and any other user-facing dialog pauses execution while the transaction is still open. During that pause every lock held by the transaction blocks other sessions. A user who walks away from the screen can suspend business-critical tables for an unbounded period.
|
|
||||||
|
|
||||||
## Best Practice
|
|
||||||
|
|
||||||
Gather every user decision before the writing phase begins. Once the decisions are known, run the transaction end-to-end without prompts.
|
|
||||||
|
|
||||||
See sample: `avoid-user-interaction-in-transactions.good.al`.
|
|
||||||
|
|
||||||
## Anti Pattern
|
|
||||||
|
|
||||||
Calling Confirm or StrMenu from inside an OnInsert, OnModify, or OnDelete trigger — or from any code path that has already started modifying records — blocks on user input while holding locks.
|
|
||||||
|
|
||||||
See sample: `avoid-user-interaction-in-transactions.bad.al`.
|
|
||||||
|
|
||||||
|
|
@ -0,0 +1,18 @@
|
||||||
|
codeunit 50239 "Perf Sample PromptInTxn Bad"
|
||||||
|
{
|
||||||
|
procedure PostOrder(DocNo: Code[20])
|
||||||
|
var
|
||||||
|
SalesHeader: Record "Sales Header";
|
||||||
|
PostConfirmQst: Label 'Post this order?';
|
||||||
|
begin
|
||||||
|
SalesHeader.LockTable();
|
||||||
|
SalesHeader.Get(SalesHeader."Document Type"::Order, DocNo);
|
||||||
|
// Lock held while the dialog is on screen — minutes or hours.
|
||||||
|
if Confirm(PostConfirmQst) then
|
||||||
|
PostSalesOrder(SalesHeader);
|
||||||
|
end;
|
||||||
|
|
||||||
|
local procedure PostSalesOrder(var SalesHeader: Record "Sales Header")
|
||||||
|
begin
|
||||||
|
end;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,18 @@
|
||||||
|
codeunit 50238 "Perf Sample PromptInTxn Good"
|
||||||
|
{
|
||||||
|
procedure PostOrder(DocNo: Code[20])
|
||||||
|
var
|
||||||
|
SalesHeader: Record "Sales Header";
|
||||||
|
PostConfirmQst: Label 'Post this order?';
|
||||||
|
begin
|
||||||
|
if not Confirm(PostConfirmQst) then
|
||||||
|
exit;
|
||||||
|
SalesHeader.LockTable();
|
||||||
|
SalesHeader.Get(SalesHeader."Document Type"::Order, DocNo);
|
||||||
|
PostSalesOrder(SalesHeader);
|
||||||
|
end;
|
||||||
|
|
||||||
|
local procedure PostSalesOrder(var SalesHeader: Record "Sales Header")
|
||||||
|
begin
|
||||||
|
end;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,26 @@
|
||||||
|
---
|
||||||
|
bc-version: [all]
|
||||||
|
domain: performance
|
||||||
|
keywords: [confirm, strmenu, dialog, transaction, lock, user-interaction]
|
||||||
|
technologies: [al]
|
||||||
|
countries: [w1]
|
||||||
|
application-area: [all]
|
||||||
|
---
|
||||||
|
|
||||||
|
# Do not hold locks while waiting for the user
|
||||||
|
|
||||||
|
## Description
|
||||||
|
|
||||||
|
A `Confirm`, `StrMenu`, modal page, or other user prompt issued from inside a write transaction stalls the transaction — and therefore every lock it holds — until the user responds. Per the upstream guidance, "Avoid user interactions (Confirm, StrMenu) inside transactions — they hold locks while waiting for user input." The wait is bounded only by the user; meanwhile other sessions block on whatever this transaction has acquired.
|
||||||
|
|
||||||
|
## Best Practice
|
||||||
|
|
||||||
|
Sequence the operation so user confirmation happens *before* any database write that takes a lock the prompt holds open. The shape is: ask the user → if confirmed, acquire locks and post. `if Confirm(...) then begin SalesHeader.LockTable(); SalesHeader.Get(DocNo); PostSalesOrder(SalesHeader); end;` keeps the lock window down to the work itself.
|
||||||
|
|
||||||
|
See sample: `avoid-user-prompts-inside-transactions.good.al`.
|
||||||
|
|
||||||
|
## Anti Pattern
|
||||||
|
|
||||||
|
`SalesHeader.LockTable(); SalesHeader.Get(DocNo); if Confirm('Post this order?') then ...;` — the lock is held for as long as the dialog is up. A user who steps away to lunch holds the lock for an hour, and every other session that touches that row blocks for the duration.
|
||||||
|
|
||||||
|
See sample: `avoid-user-prompts-inside-transactions.bad.al`.
|
||||||
|
|
@ -1,26 +0,0 @@
|
||||||
---
|
|
||||||
bc-version: [all]
|
|
||||||
domain: performance
|
|
||||||
keywords: [blob, media, mediaset, cache, image, thumbnail]
|
|
||||||
technologies: [al]
|
|
||||||
countries: [w1]
|
|
||||||
application-area: [all]
|
|
||||||
---
|
|
||||||
|
|
||||||
# Blob fields are never cached — prefer Media or MediaSet for images
|
|
||||||
|
|
||||||
## Description
|
|
||||||
|
|
||||||
`Blob` field contents are not cached by the Business Central server or the client. Every read re-fetches the full payload from the database, even when the same blob was read moments earlier in the same session. For images displayed on a page, this turns into a database round-trip per render.
|
|
||||||
|
|
||||||
`Media` and `MediaSet` are purpose-built for this and behave differently in two ways that matter for performance. First, they are cached on the client, so subsequent renders of the same image do not re-hit the database. Second, the platform generates a thumbnail when the data is saved, so a list or card page can show the thumbnail immediately and lazy-load the full-resolution image — typically via a Page Background Task — only when needed.
|
|
||||||
|
|
||||||
`Blob` remains appropriate for non-image binary data that is written once and rarely read, or for data the platform does not need to render. For any field that is displayed repeatedly — profile pictures, item images, logos on documents — `Media` or `MediaSet` is the default.
|
|
||||||
|
|
||||||
## Best Practice
|
|
||||||
|
|
||||||
Store images in `Media` or `MediaSet` fields. Bind the thumbnail to the page; load full-resolution data asynchronously when the user opens the full view. Reserve `Blob` for opaque payloads that are not rendered in the UI.
|
|
||||||
|
|
||||||
## Anti Pattern
|
|
||||||
|
|
||||||
An Item Image field defined as `Blob` and shown directly on a list page. Every scroll re-fetches every image from SQL, the list page load time scales with row count and image size, and no client-side caching mitigates the cost.
|
|
||||||
|
|
@ -0,0 +1,15 @@
|
||||||
|
codeunit 50223 "Perf Sample CalcSums Bad"
|
||||||
|
{
|
||||||
|
procedure TotalRemaining(CustomerNo: Code[20]) Total: Decimal
|
||||||
|
var
|
||||||
|
CustLedgerEntry: Record "Cust. Ledger Entry";
|
||||||
|
begin
|
||||||
|
CustLedgerEntry.SetRange("Customer No.", CustomerNo);
|
||||||
|
// One SQL query per row over a 10M-row ledger.
|
||||||
|
if CustLedgerEntry.FindSet() then
|
||||||
|
repeat
|
||||||
|
CustLedgerEntry.CalcFields("Remaining Amount");
|
||||||
|
Total += CustLedgerEntry."Remaining Amount";
|
||||||
|
until CustLedgerEntry.Next() = 0;
|
||||||
|
end;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,11 @@
|
||||||
|
codeunit 50222 "Perf Sample CalcSums Good"
|
||||||
|
{
|
||||||
|
procedure TotalRemaining(CustomerNo: Code[20]) Total: Decimal
|
||||||
|
var
|
||||||
|
CustLedgerEntry: Record "Cust. Ledger Entry";
|
||||||
|
begin
|
||||||
|
CustLedgerEntry.SetRange("Customer No.", CustomerNo);
|
||||||
|
CustLedgerEntry.CalcSums("Remaining Amount");
|
||||||
|
Total := CustLedgerEntry."Remaining Amount";
|
||||||
|
end;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,26 @@
|
||||||
|
---
|
||||||
|
bc-version: [all]
|
||||||
|
domain: performance
|
||||||
|
keywords: [calcfields, calcsums, loop, flowfield, n-plus-one, aggregation]
|
||||||
|
technologies: [al]
|
||||||
|
countries: [w1]
|
||||||
|
application-area: [all]
|
||||||
|
---
|
||||||
|
|
||||||
|
# Use CalcSums to aggregate, not CalcFields inside a loop
|
||||||
|
|
||||||
|
## Description
|
||||||
|
|
||||||
|
`CalcFields` materializes FlowField values for one record. Each call against a persistent table is "a separate SQL query"; running it inside a `repeat ... until Next() = 0` over a large table issues one query per row on top of the iteration itself. `CalcSums` answers the same aggregation question — "give me the sum of this FlowField over the filtered set" — as a single SQL statement. Per the upstream guidance, `CalcFields` inside loops on large persistent tables is "a performance problem"; the aggregation form is `CalcSums()`.
|
||||||
|
|
||||||
|
## Best Practice
|
||||||
|
|
||||||
|
When the procedure totals a FlowField (or several) across a filtered set, set the filters, then call `CalcSums("Field 1", "Field 2", ...)`. The platform issues one query; the result is read off the record's FlowField slot. Single `CalcFields` outside loops is fine, and `CalcFields` on the current row in a page's `OnAfterGetRecord` or in `OnValidate` is the standard pattern — those are per-action, not per-row over a large set.
|
||||||
|
|
||||||
|
See sample: `calcsums-instead-of-calcfields-in-loop.good.al`.
|
||||||
|
|
||||||
|
## Anti Pattern
|
||||||
|
|
||||||
|
`if CustLedgerEntry.FindSet() then repeat CustLedgerEntry.CalcFields("Remaining Amount"); Total += CustLedgerEntry."Remaining Amount"; until CustLedgerEntry.Next() = 0;` — exactly the upstream-flagged shape. The iteration is the cheap part; the per-row `CalcFields` is what scales linearly with table size.
|
||||||
|
|
||||||
|
See sample: `calcsums-instead-of-calcfields-in-loop.bad.al`.
|
||||||
|
|
@ -1,16 +0,0 @@
|
||||||
codeunit 51206 "Perf Sample CombineMA Good"
|
|
||||||
{
|
|
||||||
procedure UpdateTolerance(DocumentNo: Code[20]; ToleranceAmount: Decimal)
|
|
||||||
var
|
|
||||||
CustLedgerEntry: Record "Cust. Ledger Entry";
|
|
||||||
begin
|
|
||||||
CustLedgerEntry.SetRange("Document No.", DocumentNo);
|
|
||||||
CustLedgerEntry.SetRange(Open, true);
|
|
||||||
if CustLedgerEntry.FindSet(true) then
|
|
||||||
repeat
|
|
||||||
CustLedgerEntry."Accepted Payment Tolerance" := ToleranceAmount;
|
|
||||||
CustLedgerEntry."Accepted Pmt. Disc. Tolerance" := false;
|
|
||||||
CustLedgerEntry.Modify(false);
|
|
||||||
until CustLedgerEntry.Next() = 0;
|
|
||||||
end;
|
|
||||||
}
|
|
||||||
|
|
@ -1,26 +0,0 @@
|
||||||
---
|
|
||||||
bc-version: [all]
|
|
||||||
domain: performance
|
|
||||||
keywords: [modifyall, bulk-update, filter, scan, recordset]
|
|
||||||
technologies: [al]
|
|
||||||
countries: [w1]
|
|
||||||
application-area: [all]
|
|
||||||
---
|
|
||||||
|
|
||||||
# Combine multiple ModifyAll calls on the same recordset into a single pass
|
|
||||||
|
|
||||||
## Description
|
|
||||||
|
|
||||||
`ModifyAll(Field, Value)` issues a SQL UPDATE against every row matching the record variable's current filters, setting one field. Calling it twice on the same filtered recordset — once per field to update — produces two separate UPDATE statements, each of which has to re-locate the matching rows through the index. On a ledger-entry-scale table with ten million rows and a filter that matches a thousand, the overhead is not a doubling of the update cost but a doubling of the more expensive row-location cost. A single `FindSet(true)` + set-by-set assignment + `Modify(false)` completes both field changes in one pass.
|
|
||||||
|
|
||||||
## Best Practice
|
|
||||||
|
|
||||||
When more than one field needs to change on the same filtered recordset, iterate once with `FindSet(true)` and assign all fields per row. Reserve ModifyAll for the case where a single field change covers the whole update. If the filter set is truly huge and the trigger behaviour differs between fields, consider splitting with concrete evidence — otherwise the single-pass loop wins.
|
|
||||||
|
|
||||||
See sample: `combine-multiple-modifyall-calls.good.al`.
|
|
||||||
|
|
||||||
## Anti Pattern
|
|
||||||
|
|
||||||
Applying `SetRange` against `CustLedgerEntry` on `"Document No."` and then calling `ModifyAll("Accepted Payment Tolerance", ...)` followed by `ModifyAll("Accepted Pmt. Disc. Tolerance", false)` — two scans over the same filtered rows. On Cust. Ledger Entry with production-scale data the redundant second scan is the dominant cost.
|
|
||||||
|
|
||||||
See sample: `combine-multiple-modifyall-calls.bad.al`.
|
|
||||||
|
|
@ -1,22 +0,0 @@
|
||||||
---
|
|
||||||
bc-version: [all]
|
|
||||||
domain: performance
|
|
||||||
keywords: [setup-table, temporary, bounded-table, metadata, migration, false-positive]
|
|
||||||
technologies: [al]
|
|
||||||
countries: [w1]
|
|
||||||
application-area: [all]
|
|
||||||
---
|
|
||||||
|
|
||||||
# Do not flag performance on inherently bounded tables
|
|
||||||
|
|
||||||
## Description
|
|
||||||
|
|
||||||
Several categories of Business Central tables are so small, so rarely accessed, or so in-memory that performance heuristics that make sense on Item Ledger Entry produce noise when applied to them. Temporary records (`TableType = Temporary`, `SourceTableTemporary = true`) live in memory and any access pattern is fast. Singleton setup tables (`Sales & Receivables Setup`, `General Ledger Setup`, `*Setup` tables generally) hold one row per company. Small bounded tables — enum mappings, permission objects, Role IDs — count in the dozens. System metadata tables (`TableMetadata`, `Field`, `AllObjWithCaption`) are bounded by the object catalog. Admin, Migration, Setup, Wizard, and Hybrid* pages are used infrequently with small datasets.
|
|
||||||
|
|
||||||
## Best Practice
|
|
||||||
|
|
||||||
Skip performance findings on these categories unless the code is specifically pathological (unbounded loop that multiplies cost non-linearly). A missing SetLoadFields on a singleton Setup table is not a finding. A Count on a 30-row permission mapping is not a finding. An admin page that iterates a bounded list once per invocation is not a finding. Reserving reviewer attention for the tables where it matters is half the value of the heuristics — noise on bounded tables trains authors to ignore the signal.
|
|
||||||
|
|
||||||
## Anti Pattern
|
|
||||||
|
|
||||||
Flagging `SalesReceivablesSetup.Get()` followed by `SetLoadFields()` on a handful of fields as "missing partial record optimization". Flagging a `FindSet` + loop on `Role ID` mapping because the loop has no SetCurrentKey. Flagging a Migration codeunit for writing many records, when the entire migration runs once per customer. All three burn author attention on cases that are not regressions.
|
|
||||||
|
|
@ -0,0 +1,10 @@
|
||||||
|
codeunit 50235 "Perf Sample LockReadOnly Bad"
|
||||||
|
{
|
||||||
|
procedure GetStatus(var AgentStatus: Record "Agent Status"): Boolean
|
||||||
|
begin
|
||||||
|
// Read-only path, yet every caller's transaction now acquires UPDLOCK
|
||||||
|
// on Agent Status for the remainder of the transaction.
|
||||||
|
AgentStatus.LockTable();
|
||||||
|
exit(AgentStatus.Get());
|
||||||
|
end;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,14 @@
|
||||||
|
codeunit 50234 "Perf Sample LockReadOnly Good"
|
||||||
|
{
|
||||||
|
procedure GetStatus(var AgentStatus: Record "Agent Status"): Boolean
|
||||||
|
begin
|
||||||
|
if AgentStatus.Get() then
|
||||||
|
exit(true);
|
||||||
|
AgentStatus.LockTable();
|
||||||
|
if not AgentStatus.Get() then begin
|
||||||
|
AgentStatus.Init();
|
||||||
|
AgentStatus.Insert();
|
||||||
|
end;
|
||||||
|
exit(true);
|
||||||
|
end;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,26 @@
|
||||||
|
---
|
||||||
|
bc-version: [all]
|
||||||
|
domain: performance
|
||||||
|
keywords: [locktable, read-only, helper, contention, transaction]
|
||||||
|
technologies: [al]
|
||||||
|
countries: [w1]
|
||||||
|
application-area: [all]
|
||||||
|
---
|
||||||
|
|
||||||
|
# Do not LockTable in a read-only procedure
|
||||||
|
|
||||||
|
## Description
|
||||||
|
|
||||||
|
`LockTable` is a transaction-wide signal: from the call onward, every read against that table in the same transaction acquires `UPDLOCK`. Per the upstream guidance, "`LockTable()` before Modify/Insert/Delete in the same procedure is the correct pattern" — locking the read against the write that follows is what the call exists for. The anti-pattern is "`LockTable()` in read-only procedures — unnecessary lock contention": the procedure never writes, but the lock cost is paid by everyone sharing the transaction.
|
||||||
|
|
||||||
|
## Best Practice
|
||||||
|
|
||||||
|
Reserve `LockTable` for the read directly before a `Modify`, `Insert`, or `Delete` that depends on the read value. If a helper is sometimes called for reading and sometimes for writing, split it into separate read and write paths and call `LockTable` only on the write path. For read-only existence checks or lookups, the right primitive is `ReadIsolation` (see `prefer-readisolation-over-locktable-for-reads.md`).
|
||||||
|
|
||||||
|
See sample: `do-not-locktable-in-read-only-procedure.good.al`.
|
||||||
|
|
||||||
|
## Anti Pattern
|
||||||
|
|
||||||
|
A pure getter that opens with `Rec.LockTable();`. Every caller's transaction now acquires `UPDLOCK` on that table for every subsequent read until commit. The contention shows up as blocking on unrelated sessions whose own code path looks innocent — the locker is invisible to the blocked reader.
|
||||||
|
|
||||||
|
See sample: `do-not-locktable-in-read-only-procedure.bad.al`.
|
||||||
|
|
@ -0,0 +1,17 @@
|
||||||
|
page 50247 "Perf Sample WriteScroll Bad"
|
||||||
|
{
|
||||||
|
PageType = List;
|
||||||
|
SourceTable = Customer;
|
||||||
|
|
||||||
|
trigger OnAfterGetRecord()
|
||||||
|
begin
|
||||||
|
// One DB write per row displayed, every time the user scrolls.
|
||||||
|
Rec."Reminder Terms Code" := CalcReminderTerms();
|
||||||
|
Rec.Modify();
|
||||||
|
end;
|
||||||
|
|
||||||
|
local procedure CalcReminderTerms(): Code[10]
|
||||||
|
begin
|
||||||
|
exit('');
|
||||||
|
end;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,18 @@
|
||||||
|
page 50246 "Perf Sample WriteScroll Good"
|
||||||
|
{
|
||||||
|
PageType = List;
|
||||||
|
SourceTable = Customer;
|
||||||
|
|
||||||
|
var
|
||||||
|
ShowWarning: Boolean;
|
||||||
|
|
||||||
|
trigger OnAfterGetRecord()
|
||||||
|
begin
|
||||||
|
ShowWarning := CalcWarning();
|
||||||
|
end;
|
||||||
|
|
||||||
|
local procedure CalcWarning(): Boolean
|
||||||
|
begin
|
||||||
|
exit(false);
|
||||||
|
end;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,26 @@
|
||||||
|
---
|
||||||
|
bc-version: [all]
|
||||||
|
domain: performance
|
||||||
|
keywords: [page-trigger, onaftergetrecord, modify, display, scroll, db-write]
|
||||||
|
technologies: [al]
|
||||||
|
countries: [w1]
|
||||||
|
application-area: [all]
|
||||||
|
---
|
||||||
|
|
||||||
|
# Do not Modify inside OnAfterGetRecord
|
||||||
|
|
||||||
|
## Description
|
||||||
|
|
||||||
|
A list page's `OnAfterGetRecord` fires once per visible row, every time the user scrolls, sorts, or refreshes. A `Modify` inside that trigger means a database write per row displayed. Per the upstream guidance, "`Modify()` here means a DB write on every scroll. Use page variables for display-only state instead." `OnAfterGetCurrRecord` (single record on selection), `OnOpenPage`, and `OnInit` fire once or at much lower frequency and tolerate one-time setup logic.
|
||||||
|
|
||||||
|
## Best Practice
|
||||||
|
|
||||||
|
When the trigger needs to compute display-only state per row, write the result into a page variable (a global on the page object) rather than back to the database. Reserve `Modify` for triggers that fire on an explicit user action — `OnAction`, validation triggers, `OnQueryClosePage` — where one action maps to one write.
|
||||||
|
|
||||||
|
See sample: `do-not-modify-in-onaftergetrecord.good.al`.
|
||||||
|
|
||||||
|
## Anti Pattern
|
||||||
|
|
||||||
|
`trigger OnAfterGetRecord() begin Rec."Warning Flag" := CalcWarning(); Rec.Modify(); end;` — on a list page over a moderately sized table, scrolling through fifty rows produces fifty writes. The page feels slow, the table accumulates churn, and the warning flag — which is recomputed on every refresh anyway — never needed persistence.
|
||||||
|
|
||||||
|
See sample: `do-not-modify-in-onaftergetrecord.bad.al`.
|
||||||
|
|
@ -1,15 +0,0 @@
|
||||||
pageextension 51209 "Perf Sample NoModifyOAGR Bad" extends "Customer List"
|
|
||||||
{
|
|
||||||
trigger OnAfterGetRecord()
|
|
||||||
begin
|
|
||||||
// Every scroll writes to the database. Every OnModify subscriber on
|
|
||||||
// Customer fires alongside. Write volume scales with mouse-wheel speed.
|
|
||||||
Rec."Last Warning Flag" := CalcWarning();
|
|
||||||
Rec.Modify();
|
|
||||||
end;
|
|
||||||
|
|
||||||
local procedure CalcWarning(): Boolean
|
|
||||||
begin
|
|
||||||
exit(false);
|
|
||||||
end;
|
|
||||||
}
|
|
||||||
|
|
@ -1,35 +0,0 @@
|
||||||
page 51208 "Perf Sample NoModifyOAGR Good"
|
|
||||||
{
|
|
||||||
PageType = List;
|
|
||||||
SourceTable = Customer;
|
|
||||||
|
|
||||||
layout
|
|
||||||
{
|
|
||||||
area(Content)
|
|
||||||
{
|
|
||||||
repeater(Group)
|
|
||||||
{
|
|
||||||
field("No."; Rec."No.") { ApplicationArea = All; }
|
|
||||||
field(WarningFlag; ShowWarning)
|
|
||||||
{
|
|
||||||
ApplicationArea = All;
|
|
||||||
Caption = 'Warning';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
trigger OnAfterGetRecord()
|
|
||||||
begin
|
|
||||||
// Page-local variable. No database write per row.
|
|
||||||
ShowWarning := CalcWarning(Rec);
|
|
||||||
end;
|
|
||||||
|
|
||||||
var
|
|
||||||
ShowWarning: Boolean;
|
|
||||||
|
|
||||||
local procedure CalcWarning(var Customer: Record Customer): Boolean
|
|
||||||
begin
|
|
||||||
exit(false);
|
|
||||||
end;
|
|
||||||
}
|
|
||||||
|
|
@ -1,26 +0,0 @@
|
||||||
---
|
|
||||||
bc-version: [all]
|
|
||||||
domain: performance
|
|
||||||
keywords: [onaftergetrecord, modify, page, trigger, write-per-scroll]
|
|
||||||
technologies: [al]
|
|
||||||
countries: [w1]
|
|
||||||
application-area: [all]
|
|
||||||
---
|
|
||||||
|
|
||||||
# Do not Modify records inside OnAfterGetRecord
|
|
||||||
|
|
||||||
## Description
|
|
||||||
|
|
||||||
`OnAfterGetRecord` fires for every row the page or repeater renders. On a list page the user scrolls through, the trigger runs hundreds of times per second. A `Modify()` call inside the trigger writes to the database for every row scrolled past — the user's mouse wheel generates the write storm, and the effect compounds with every other subscriber that reacts to the OnModify event. The database activity is usually invisible to the author in development, because the list page loads ten rows; on a production tenant scrolling through thousands of rows, the page becomes the top source of write volume.
|
|
||||||
|
|
||||||
## Best Practice
|
|
||||||
|
|
||||||
Derive display-only state into a page-level variable and bind that variable to the field control instead of writing to `Rec`. If the computed value is genuinely a stored attribute of the record, compute it once at the authoring site (OnValidate, OnInsert) and display the stored value on the list — do not recompute and rewrite on every render.
|
|
||||||
|
|
||||||
See sample: `do-not-modify-records-in-onaftergetrecord.good.al`.
|
|
||||||
|
|
||||||
## Anti Pattern
|
|
||||||
|
|
||||||
An OnAfterGetRecord body that assigns a computed value to `Rec."Warning Flag"` and calls `Rec.Modify()` so the flag persists. The write fires per scroll, per user, per second — and every subscriber on the Rec's OnModify fires alongside.
|
|
||||||
|
|
||||||
See sample: `do-not-modify-records-in-onaftergetrecord.bad.al`.
|
|
||||||
|
|
@ -1,34 +0,0 @@
|
||||||
page 51203 "Perf Sample ReGetRec Bad"
|
|
||||||
{
|
|
||||||
PageType = List;
|
|
||||||
SourceTable = "Assembly Line";
|
|
||||||
|
|
||||||
layout
|
|
||||||
{
|
|
||||||
area(Content)
|
|
||||||
{
|
|
||||||
repeater(Group)
|
|
||||||
{
|
|
||||||
field("No."; Rec."No.") { ApplicationArea = All; }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
trigger OnAfterGetRecord()
|
|
||||||
var
|
|
||||||
AssemblyLineRec: Record "Assembly Line";
|
|
||||||
begin
|
|
||||||
// Redundant Get. The page runtime already loaded this row into Rec.
|
|
||||||
// At list-page scale this fires hundreds of times per scroll.
|
|
||||||
AssemblyLineRec.Get(Rec."Document Type", Rec."Document No.", Rec."Line No.");
|
|
||||||
ShowWarning := CheckAvailability(AssemblyLineRec);
|
|
||||||
end;
|
|
||||||
|
|
||||||
var
|
|
||||||
ShowWarning: Boolean;
|
|
||||||
|
|
||||||
local procedure CheckAvailability(var AssemblyLine: Record "Assembly Line"): Boolean
|
|
||||||
begin
|
|
||||||
exit(false);
|
|
||||||
end;
|
|
||||||
}
|
|
||||||
|
|
@ -1,30 +0,0 @@
|
||||||
page 51202 "Perf Sample ReGetRec Good"
|
|
||||||
{
|
|
||||||
PageType = List;
|
|
||||||
SourceTable = "Assembly Line";
|
|
||||||
|
|
||||||
layout
|
|
||||||
{
|
|
||||||
area(Content)
|
|
||||||
{
|
|
||||||
repeater(Group)
|
|
||||||
{
|
|
||||||
field("No."; Rec."No.") { ApplicationArea = All; }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
trigger OnAfterGetRecord()
|
|
||||||
begin
|
|
||||||
// Rec already holds the current row's values; no Get needed.
|
|
||||||
ShowWarning := CheckAvailability(Rec);
|
|
||||||
end;
|
|
||||||
|
|
||||||
var
|
|
||||||
ShowWarning: Boolean;
|
|
||||||
|
|
||||||
local procedure CheckAvailability(var AssemblyLine: Record "Assembly Line"): Boolean
|
|
||||||
begin
|
|
||||||
exit(false);
|
|
||||||
end;
|
|
||||||
}
|
|
||||||
|
|
@ -1,26 +0,0 @@
|
||||||
---
|
|
||||||
bc-version: [all]
|
|
||||||
domain: performance
|
|
||||||
keywords: [onaftergetrecord, get, rec, page-runtime, redundant-fetch]
|
|
||||||
technologies: [al]
|
|
||||||
countries: [w1]
|
|
||||||
application-area: [all]
|
|
||||||
---
|
|
||||||
|
|
||||||
# Do not re-Get the current record inside OnAfterGetRecord
|
|
||||||
|
|
||||||
## Description
|
|
||||||
|
|
||||||
The page runtime loads the current record before firing `OnAfterGetRecord` — `Rec` already holds the row's values when the trigger body runs. Calling `Rec.Get(...)` (or any equivalent Get against the same key) inside the trigger issues a second database round-trip for data the runtime just fetched. On a list page that displays hundreds of rows during a scroll, this turns into hundreds of wasted round-trips per user interaction. The same concern applies to `OnAfterGetCurrRecord` on card and document pages, though the impact is smaller because the trigger fires per selection rather than per row.
|
|
||||||
|
|
||||||
## Best Practice
|
|
||||||
|
|
||||||
Read from `Rec` directly. When a helper method needs a different record, pass `Rec` as an argument or let the helper fetch its own lookup once; do not re-Get the current row. If the code truly needs a fresh value because it was modified by another session, design the refresh explicitly — document it in a comment — rather than paying the cost on every trigger fire.
|
|
||||||
|
|
||||||
See sample: `do-not-re-get-rec-inside-onaftergetrecord.good.al`.
|
|
||||||
|
|
||||||
## Anti Pattern
|
|
||||||
|
|
||||||
An `OnAfterGetRecord` trigger body that starts with `AssemblyLineRec.Get("Document Type", "Document No.", "Line No.")` for the same keys the page runtime has already used — the Get restates what `Rec` already holds. Replace with a direct call against `Rec` (`CheckAvailability(Rec)`).
|
|
||||||
|
|
||||||
See sample: `do-not-re-get-rec-inside-onaftergetrecord.bad.al`.
|
|
||||||
|
|
@ -0,0 +1,12 @@
|
||||||
|
page 50249 "Perf Sample TempAPI Bad"
|
||||||
|
{
|
||||||
|
PageType = API;
|
||||||
|
APIPublisher = 'perf';
|
||||||
|
APIGroup = 'sample';
|
||||||
|
APIVersion = 'v1.0';
|
||||||
|
EntityName = 'outboxEmail';
|
||||||
|
EntitySetName = 'outboxEmails';
|
||||||
|
SourceTable = "Sent Email";
|
||||||
|
// SourceTableTemporary removed — every request now hits SQL.
|
||||||
|
DelayedInsert = true;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,12 @@
|
||||||
|
page 50248 "Perf Sample TempAPI Good"
|
||||||
|
{
|
||||||
|
PageType = API;
|
||||||
|
APIPublisher = 'perf';
|
||||||
|
APIGroup = 'sample';
|
||||||
|
APIVersion = 'v1.0';
|
||||||
|
EntityName = 'outboxEmail';
|
||||||
|
EntitySetName = 'outboxEmails';
|
||||||
|
SourceTable = "Sent Email";
|
||||||
|
SourceTableTemporary = true;
|
||||||
|
DelayedInsert = true;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,26 @@
|
||||||
|
---
|
||||||
|
bc-version: [all]
|
||||||
|
domain: performance
|
||||||
|
keywords: [sourcetabletemporary, api-page, temporary, persistent, in-memory]
|
||||||
|
technologies: [al]
|
||||||
|
countries: [w1]
|
||||||
|
application-area: [all]
|
||||||
|
---
|
||||||
|
|
||||||
|
# Removing SourceTableTemporary on an API page switches it from in-memory to persistent
|
||||||
|
|
||||||
|
## Description
|
||||||
|
|
||||||
|
`SourceTableTemporary = true` on a page makes the page's record buffer in-memory only — reads and writes do not touch SQL. The same applies to `TableType = Temporary` on a record. Removing either turns operations that were memory accesses into database round-trips. Per the upstream guidance, the change is "potentially increasing DB load for high-volume paths (API pages, background tasks)" — and on API pages especially, the change is invisible at the page definition but visible at production scale.
|
||||||
|
|
||||||
|
## Best Practice
|
||||||
|
|
||||||
|
If a page or record was declared temporary on purpose — to buffer payloads, accept synthetic rows, or expose computed data through an API surface without persisting it — keep it temporary. When removing the property looks necessary, audit the call sites first: a temporary API page is often consumed by integrations that issue many calls per minute, and the round-trip cost is paid per call. If persistence is genuinely required, weigh storage and lock cost against alternatives (a regular table the API page reads from, an event-driven write).
|
||||||
|
|
||||||
|
See sample: `do-not-remove-sourcetabletemporary-from-api-page.good.al`.
|
||||||
|
|
||||||
|
## Anti Pattern
|
||||||
|
|
||||||
|
Dropping `SourceTableTemporary = true` from an API page to "simplify" it, without revisiting the access pattern. The page begins issuing real SQL on every request; locks now contend with other writers; bulk integrations slow proportionally. The same trap exists for a record that was `TableType = Temporary` and gets demoted to a persistent table to make a debugger view easier.
|
||||||
|
|
||||||
|
See sample: `do-not-remove-sourcetabletemporary-from-api-page.bad.al`.
|
||||||
|
|
@ -1,22 +0,0 @@
|
||||||
---
|
|
||||||
bc-version: [all]
|
|
||||||
domain: performance
|
|
||||||
keywords: [flowfield, calcformula, regression, source-table, sift]
|
|
||||||
technologies: [al]
|
|
||||||
countries: [w1]
|
|
||||||
application-area: [all]
|
|
||||||
---
|
|
||||||
|
|
||||||
# Do not retarget a FlowField's CalcFormula to a larger source table
|
|
||||||
|
|
||||||
## Description
|
|
||||||
|
|
||||||
A FlowField's CalcFormula is evaluated every time the field is read — every time the page renders, every CalcFields call, every list page filter that references the field. Changing the CalcFormula's source table from a smaller, bounded, or already-filtered table to a larger unfiltered one multiplies the per-read cost. A common shape is the refactor from "Posted X" to "X" — the unposted line table is typically an order of magnitude larger and carries rows that the original FlowField never considered. The change compiles and may look like a simple scope widening; the performance impact is not visible until production load.
|
|
||||||
|
|
||||||
## Best Practice
|
|
||||||
|
|
||||||
When a FlowField CalcFormula changes source table, evaluate the before/after row counts, ensure a SIFT key exists on the new source that matches the formula's filters (see `add-sift-keys-for-flowfields`), and verify no existing callers rely on the tighter scope. If the widening is intentional, the corresponding SIFT keys on the new source must ship in the same PR.
|
|
||||||
|
|
||||||
## Anti Pattern
|
|
||||||
|
|
||||||
Changing a `sum("Posted Expense Report Line"."Amount" where(...))` formula to `sum("Expense Report Line"."Amount" where(...))` without touching the source table's keys. Every list page and dashboard that reads the FlowField now aggregates over the unposted table too, almost always without a supporting SIFT key.
|
|
||||||
|
|
@ -1,27 +0,0 @@
|
||||||
---
|
|
||||||
bc-version: [all]
|
|
||||||
domain: performance
|
|
||||||
keywords: [filter, setrange, setfilter, findset, scan]
|
|
||||||
technologies: [al]
|
|
||||||
countries: [w1]
|
|
||||||
application-area: [all]
|
|
||||||
---
|
|
||||||
|
|
||||||
# Filter before you find
|
|
||||||
|
|
||||||
## Description
|
|
||||||
|
|
||||||
Every call to FindSet, Find, or FindFirst on an unfiltered record variable scans the entire table. On hot tables (ledger entries, value entries, sales invoice lines) a production dataset can easily be millions of rows, so the cost of forgetting a filter is orders of magnitude worse than the cost of applying one.
|
|
||||||
|
|
||||||
## Best Practice
|
|
||||||
|
|
||||||
Apply SetRange or SetFilter to narrow the record set before calling FindSet or Find. The filters should match a key on the table (see set-current-key-to-match-filters). When iterating rows that belong to a parent record, set all key-field filters before the find call — never inside the repeat loop.
|
|
||||||
|
|
||||||
See sample: `filter-before-find.good.al`.
|
|
||||||
|
|
||||||
## Anti Pattern
|
|
||||||
|
|
||||||
Calling FindSet with no filters and then discarding rows inside the loop with an if-statement forces the platform to read every row of the table before your code even runs.
|
|
||||||
|
|
||||||
See sample: `filter-before-find.bad.al`.
|
|
||||||
|
|
||||||
|
|
@ -0,0 +1,25 @@
|
||||||
|
codeunit 50237 "Perf Sample FindSetTrue Bad"
|
||||||
|
{
|
||||||
|
procedure NormalizeNames()
|
||||||
|
var
|
||||||
|
Customer: Record Customer;
|
||||||
|
begin
|
||||||
|
// Read takes a shared lock; the Modify then needs to upgrade — that gap
|
||||||
|
// is the deadlock window FindSet(true) was designed to close.
|
||||||
|
if Customer.FindSet() then
|
||||||
|
repeat
|
||||||
|
Customer.Name := UpperCase(Customer.Name);
|
||||||
|
Customer.Modify();
|
||||||
|
until Customer.Next() = 0;
|
||||||
|
end;
|
||||||
|
|
||||||
|
procedure ReadOnlyOverlocked()
|
||||||
|
var
|
||||||
|
Customer: Record Customer;
|
||||||
|
begin
|
||||||
|
// No Modify in the loop, yet every row is read under UpdLock.
|
||||||
|
if Customer.FindSet(true) then
|
||||||
|
repeat
|
||||||
|
until Customer.Next() = 0;
|
||||||
|
end;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,23 @@
|
||||||
|
codeunit 50236 "Perf Sample FindSetTrue Good"
|
||||||
|
{
|
||||||
|
procedure NormalizeNames()
|
||||||
|
var
|
||||||
|
Customer: Record Customer;
|
||||||
|
begin
|
||||||
|
if Customer.FindSet(true) then
|
||||||
|
repeat
|
||||||
|
Customer.Name := UpperCase(Customer.Name);
|
||||||
|
Customer.Modify();
|
||||||
|
until Customer.Next() = 0;
|
||||||
|
end;
|
||||||
|
|
||||||
|
procedure SumBalances() Total: Decimal
|
||||||
|
var
|
||||||
|
Customer: Record Customer;
|
||||||
|
begin
|
||||||
|
if Customer.FindSet() then
|
||||||
|
repeat
|
||||||
|
Total += Customer."Balance (LCY)";
|
||||||
|
until Customer.Next() = 0;
|
||||||
|
end;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,26 @@
|
||||||
|
---
|
||||||
|
bc-version: [all]
|
||||||
|
domain: performance
|
||||||
|
keywords: [findset, updlock, readisolation, locking, modify, obsolete-syntax]
|
||||||
|
technologies: [al]
|
||||||
|
countries: [w1]
|
||||||
|
application-area: [all]
|
||||||
|
---
|
||||||
|
|
||||||
|
# FindSet(true) applies UpdLock on the read; the two-parameter form is obsolete
|
||||||
|
|
||||||
|
## Description
|
||||||
|
|
||||||
|
`FindSet()` and `FindSet(false)` are read-only — no locking. Per the upstream guidance, `FindSet(true)` "signifies the intent is to modify records" and "sets `ReadIsolation::UpdLock` on the record before finding rows." That is exactly the right shape when the loop body modifies each row: the read takes the same lock the modification will need, avoiding the deadlock window between an unlocked read and a later upgrade. The older two-parameter form `FindSet(ForUpdate, UpdateKey)` is obsolete — only the single-parameter signature should appear in new code.
|
||||||
|
|
||||||
|
## Best Practice
|
||||||
|
|
||||||
|
Use `FindSet(true)` only when the loop body genuinely modifies the iterated rows; use `FindSet()` (or `FindSet(false)`) when the loop only reads. Do not write `FindSet(true, true)` or `FindSet(true, false)` — the two-parameter form is the obsolete signature.
|
||||||
|
|
||||||
|
See sample: `findset-true-applies-updlock-on-read.good.al`.
|
||||||
|
|
||||||
|
## Anti Pattern
|
||||||
|
|
||||||
|
`FindSet(true)` on a loop that does not modify the iterated rows takes an `UpdLock` the work does not need; competing readers and writers stall against a lock the loop never uses. The mirror anti-pattern is `FindSet()` (no parameter) on a loop that *does* modify each row — the read takes a shared lock, the `Modify` then needs to upgrade, and the gap between them is a deadlock candidate.
|
||||||
|
|
||||||
|
See sample: `findset-true-applies-updlock-on-read.bad.al`.
|
||||||
|
|
@ -0,0 +1,15 @@
|
||||||
|
tableextension 50226 "Perf Sample SIFT Bad Cust" extends Customer
|
||||||
|
{
|
||||||
|
fields
|
||||||
|
{
|
||||||
|
// No SIFT key on Detailed Cust. Ledg. Entry for (Customer No.) with
|
||||||
|
// "Debit Amount" in SumIndexFields — the sum falls back to row-by-row
|
||||||
|
// aggregation over a ledger-scale table.
|
||||||
|
field(50226; "Perf Sample Total Debit"; Decimal)
|
||||||
|
{
|
||||||
|
FieldClass = FlowField;
|
||||||
|
CalcFormula = sum("Detailed Cust. Ledg. Entry"."Debit Amount"
|
||||||
|
where("Customer No." = field("No.")));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,23 @@
|
||||||
|
tableextension 50224 "Perf Sample SIFT Good Ext" extends "Detailed Cust. Ledg. Entry"
|
||||||
|
{
|
||||||
|
keys
|
||||||
|
{
|
||||||
|
key(PerfSampleByCustomer; "Customer No.", "Posting Date")
|
||||||
|
{
|
||||||
|
SumIndexFields = "Debit Amount";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
tableextension 50225 "Perf Sample SIFT Good Cust" extends Customer
|
||||||
|
{
|
||||||
|
fields
|
||||||
|
{
|
||||||
|
field(50225; "Perf Sample Total Debit"; Decimal)
|
||||||
|
{
|
||||||
|
FieldClass = FlowField;
|
||||||
|
CalcFormula = sum("Detailed Cust. Ledg. Entry"."Debit Amount"
|
||||||
|
where("Customer No." = field("No.")));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,26 @@
|
||||||
|
---
|
||||||
|
bc-version: [all]
|
||||||
|
domain: performance
|
||||||
|
keywords: [flowfield, sumindexfields, sift, key, calcformula, aa0232]
|
||||||
|
technologies: [al]
|
||||||
|
countries: [w1]
|
||||||
|
application-area: [all]
|
||||||
|
---
|
||||||
|
|
||||||
|
# A FlowField needs a source-table key that covers its CalcFormula
|
||||||
|
|
||||||
|
## Description
|
||||||
|
|
||||||
|
A FlowField is computed by SQL on demand. CodeCop AA0232 — "FlowFields should be indexed with SumIndexFields on corresponding keys" — captures the indexing requirement: the source table must declare a key that includes the fields the `CalcFormula` filters on, with the aggregated field listed in that key's `SumIndexFields`. When that alignment is in place, the platform answers `CalcFields`/`CalcSums` from SIFT; without it, the same query falls back to a row-by-row aggregation on what is often a ledger-scale table. Per the upstream guidance, "Missing SIFT indices cause performance issues on List pages."
|
||||||
|
|
||||||
|
## Best Practice
|
||||||
|
|
||||||
|
When introducing or changing a FlowField, walk the `CalcFormula`'s `WHERE` clause field by field and verify the source table has a key whose key fields cover those filters, with the aggregated field in `SumIndexFields`. The same applies when the destination side of the FlowField filter is a list-page column: the page filter triggers the FlowField on every visible row, and only SIFT keeps that affordable.
|
||||||
|
|
||||||
|
See sample: `flowfield-source-key-needs-sumindexfields.good.al`.
|
||||||
|
|
||||||
|
## Anti Pattern
|
||||||
|
|
||||||
|
A `sum` FlowField against a large source table with no matching SIFT key. Each calculation aggregates rows directly; on a ledger-sized source the FlowField becomes the slowest column on every page that displays it. Pointing an existing FlowField's `CalcFormula` at a larger source table without verifying the new source's keys is the same trap a step removed — the upstream review guidance flags it as "CalcFormula changed to larger source table".
|
||||||
|
|
||||||
|
See sample: `flowfield-source-key-needs-sumindexfields.bad.al`.
|
||||||
|
|
@ -1,16 +0,0 @@
|
||||||
codeunit 51200 "Perf Sample GuardBeforeGet Good"
|
|
||||||
{
|
|
||||||
procedure HandleLine(var PurchaseLine: Record "Purchase Line")
|
|
||||||
var
|
|
||||||
PurchaseHeader: Record "Purchase Header";
|
|
||||||
begin
|
|
||||||
// Cheap in-memory check first. Get only when the subsequent code needs the header.
|
|
||||||
if PurchaseLine."Selected Alloc. Account No." = '' then
|
|
||||||
exit;
|
|
||||||
|
|
||||||
if not PurchaseHeader.Get(PurchaseLine."Document Type", PurchaseLine."Document No.") then
|
|
||||||
exit;
|
|
||||||
|
|
||||||
// Work with PurchaseHeader.
|
|
||||||
end;
|
|
||||||
}
|
|
||||||
|
|
@ -1,26 +0,0 @@
|
||||||
---
|
|
||||||
bc-version: [all]
|
|
||||||
domain: performance
|
|
||||||
keywords: [get, guard, early-exit, wasted-fetch, conditional]
|
|
||||||
technologies: [al]
|
|
||||||
countries: [w1]
|
|
||||||
application-area: [all]
|
|
||||||
---
|
|
||||||
|
|
||||||
# Place guard conditions before Get, not after
|
|
||||||
|
|
||||||
## Description
|
|
||||||
|
|
||||||
A `Record.Get(Key)` is a database round-trip. When the call site also contains an early-exit condition that may fire before the fetched record is used, the order of the two matters: `Get` first followed by a guard that may exit means every call pays the round-trip, including the calls that immediately return. Flipping the order — evaluate the guard first, `Get` only when needed — costs nothing in the happy path and turns the wasted round-trip into zero work on the exit path. The savings compound on hot tables and on code paths entered many times per user action.
|
|
||||||
|
|
||||||
## Best Practice
|
|
||||||
|
|
||||||
Evaluate cheap, in-memory conditions first. Only issue the `Get` (or `FindFirst`, `FindLast`) when the subsequent code actually needs the record's values. For complex procedures with multiple exit conditions, sort them cheapest-first: in-memory checks, then single-record lookups, then set iteration.
|
|
||||||
|
|
||||||
See sample: `guard-before-get-not-after.good.al`.
|
|
||||||
|
|
||||||
## Anti Pattern
|
|
||||||
|
|
||||||
`PurchaseHeader.Get(PurchaseLine."Document Type", PurchaseLine."Document No."); if PurchaseLine."Selected Alloc. Account No." = '' then exit;` — the Get fires on every call; the exit discards the result for every call where `Selected Alloc. Account No.` is blank.
|
|
||||||
|
|
||||||
See sample: `guard-before-get-not-after.bad.al`.
|
|
||||||
|
|
@ -0,0 +1,18 @@
|
||||||
|
codeunit 50257 "Perf Sample EventGuard Bad"
|
||||||
|
{
|
||||||
|
[EventSubscriber(ObjectType::Table, Database::"Sales Line", 'OnAfterValidateEvent', 'Quantity', false, false)]
|
||||||
|
local procedure OnAfterValidateQuantity(var Rec: Record "Sales Line")
|
||||||
|
var
|
||||||
|
Item: Record Item;
|
||||||
|
begin
|
||||||
|
// Item.Get fires on every Quantity edit — including lines whose Type is
|
||||||
|
// not Item. No cheap guard, no SetLoadFields.
|
||||||
|
Item.Get(Rec."No.");
|
||||||
|
if Item."Item Category Code" <> '' then
|
||||||
|
RecalculatePrice(Rec, Item);
|
||||||
|
end;
|
||||||
|
|
||||||
|
local procedure RecalculatePrice(var SalesLine: Record "Sales Line"; var Item: Record Item)
|
||||||
|
begin
|
||||||
|
end;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,19 @@
|
||||||
|
codeunit 50256 "Perf Sample EventGuard Good"
|
||||||
|
{
|
||||||
|
[EventSubscriber(ObjectType::Table, Database::"Sales Line", 'OnAfterValidateEvent', 'Quantity', false, false)]
|
||||||
|
local procedure OnAfterValidateQuantity(var Rec: Record "Sales Line")
|
||||||
|
var
|
||||||
|
Item: Record Item;
|
||||||
|
begin
|
||||||
|
if Rec.Type <> Rec.Type::Item then
|
||||||
|
exit;
|
||||||
|
Item.SetLoadFields("Item Category Code");
|
||||||
|
if Item.Get(Rec."No.") then
|
||||||
|
if Item."Item Category Code" <> '' then
|
||||||
|
RecalculatePrice(Rec, Item);
|
||||||
|
end;
|
||||||
|
|
||||||
|
local procedure RecalculatePrice(var SalesLine: Record "Sales Line"; var Item: Record Item)
|
||||||
|
begin
|
||||||
|
end;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,26 @@
|
||||||
|
---
|
||||||
|
bc-version: [all]
|
||||||
|
domain: performance
|
||||||
|
keywords: [event-subscriber, guard, db-call, frequently-fired, validate]
|
||||||
|
technologies: [al]
|
||||||
|
countries: [w1]
|
||||||
|
application-area: [all]
|
||||||
|
---
|
||||||
|
|
||||||
|
# Guard event subscribers with cheap checks before any database call
|
||||||
|
|
||||||
|
## Description
|
||||||
|
|
||||||
|
Event subscribers fire on every event matching their signature — for `OnAfterValidateEvent` on a hot field like `Sales Line.Quantity`, that is every quantity edit by every user. Per the upstream guidance, "Keep event subscriber code lightweight" and "Avoid database operations in frequently-fired events — guard with cheap checks first." A `Get` or `FindFirst` at the top of such a subscriber pays a database round-trip on every fire, including the calls for which the subscriber's work was not needed.
|
||||||
|
|
||||||
|
## Best Practice
|
||||||
|
|
||||||
|
Open the subscriber with an in-memory predicate that filters out the calls the subscriber does not handle — record type, document type, status, parameter-passed flags. Only after the cheap guard passes should the body issue a database call, and only with `SetLoadFields` for the columns the body actually reads.
|
||||||
|
|
||||||
|
See sample: `guard-event-subscribers-before-db-call.good.al`.
|
||||||
|
|
||||||
|
## Anti Pattern
|
||||||
|
|
||||||
|
`[EventSubscriber(...'OnAfterValidateEvent', 'Quantity', ...)] local procedure ... var Item: Record Item; begin Item.Get(Rec."No."); if Item.HasCustomPricing() then ...;` — `Item.Get` runs on every quantity change, including changes to lines whose `Type` is not `Item`. A pre-check `if Rec.Type <> Rec.Type::Item then exit;` ahead of the `Get` removes most of the calls.
|
||||||
|
|
||||||
|
See sample: `guard-event-subscribers-before-db-call.bad.al`.
|
||||||
|
|
@ -1,24 +0,0 @@
|
||||||
---
|
|
||||||
bc-version: [all]
|
|
||||||
domain: performance
|
|
||||||
keywords: [flowfield, visible, enabled, page, calcfields, feature-management]
|
|
||||||
technologies: [al]
|
|
||||||
countries: [w1]
|
|
||||||
application-area: [all]
|
|
||||||
---
|
|
||||||
|
|
||||||
# Hidden FlowFields still calculate on pages
|
|
||||||
|
|
||||||
## Description
|
|
||||||
|
|
||||||
Setting `Visible = false` or `Enabled = false` on a FlowField hides the control but does not suppress the calculation. The server still runs the underlying CalcFields for every row the page renders. On a list page over a large table, the invisible column keeps consuming the same SQL as a visible one — the hiding is cosmetic only, and a diff that "turns off" an expensive FlowField by flipping `Visible` fixes nothing on the server.
|
|
||||||
|
|
||||||
There are two correct remedies. The durable one is to remove the FlowField from the page or page-extension definition entirely — property toggles are not enough. The environment-level one, available where supported, is the **Calculate only visible FlowFields** feature in Feature Management; when enabled, the AL runtime skips calculation for non-visible FlowFields on pages. The feature is opt-in and administrator-controlled, so code cannot assume it is active.
|
|
||||||
|
|
||||||
## Best Practice
|
|
||||||
|
|
||||||
Remove unused or hidden FlowFields from the page or page extension. If the field is needed for some users but expensive for others, factor into a dedicated page variant rather than hiding it in place. Do not rely on `Visible = false` as a performance fix unless the tenant has enabled the Calculate only visible FlowFields feature and that assumption is acceptable.
|
|
||||||
|
|
||||||
## Anti Pattern
|
|
||||||
|
|
||||||
A performance PR that sets `Visible = false` on an expensive FlowField on a list page and claims the column no longer impacts load time. The control disappears from the UI, the CalcFields still runs for every row, and the list page stays slow.
|
|
||||||
|
|
@ -1,12 +0,0 @@
|
||||||
codeunit 50140 "Perf Sample Subscriber Bad"
|
|
||||||
{
|
|
||||||
[EventSubscriber(ObjectType::Table, Database::"Sales Line", 'OnAfterValidateEvent', 'No.', false, false)]
|
|
||||||
local procedure HeavyWorkOnSalesLineNo(var Rec: Record "Sales Line"; var xRec: Record "Sales Line")
|
|
||||||
var
|
|
||||||
HttpClient: HttpClient;
|
|
||||||
HttpResponse: HttpResponseMessage;
|
|
||||||
begin
|
|
||||||
// synchronous external call on a hot event
|
|
||||||
HttpClient.Get('https://example.com/validate?no=' + Rec."No.", HttpResponse);
|
|
||||||
end;
|
|
||||||
}
|
|
||||||
|
|
@ -1,20 +0,0 @@
|
||||||
codeunit 50930 "Perf Sample Subscriber Good"
|
|
||||||
{
|
|
||||||
[EventSubscriber(ObjectType::Table, Database::"Sales Line", 'OnAfterValidateEvent', 'No.', false, false)]
|
|
||||||
local procedure OnAfterValidateSalesLineNo(var Rec: Record "Sales Line")
|
|
||||||
var
|
|
||||||
Item: Record Item;
|
|
||||||
begin
|
|
||||||
if Rec.Type <> Rec.Type::Item then
|
|
||||||
exit;
|
|
||||||
|
|
||||||
Item.SetLoadFields("Costing Method");
|
|
||||||
if Item.Get(Rec."No.") then
|
|
||||||
if Item."Costing Method" = Item."Costing Method"::Specific then
|
|
||||||
UpdateSpecificCostingState(Rec);
|
|
||||||
end;
|
|
||||||
|
|
||||||
local procedure UpdateSpecificCostingState(var SalesLine: Record "Sales Line")
|
|
||||||
begin
|
|
||||||
end;
|
|
||||||
}
|
|
||||||
|
|
@ -1,29 +0,0 @@
|
||||||
---
|
|
||||||
bc-version: [all]
|
|
||||||
domain: performance
|
|
||||||
keywords: [event, subscriber, publisher, extension]
|
|
||||||
technologies: [al]
|
|
||||||
countries: [w1]
|
|
||||||
application-area: [all]
|
|
||||||
---
|
|
||||||
|
|
||||||
# Keep event subscribers lightweight
|
|
||||||
|
|
||||||
> Contributions welcome — open a PR to refine or extend this article.
|
|
||||||
|
|
||||||
## Description
|
|
||||||
|
|
||||||
Event subscribers run synchronously on the publisher's thread. If a subscriber does heavy work — a database query, a web service call, a layout render — every caller of the publisher pays that cost. Subscribers on hot events (OnAfterValidate on common fields, OnBeforeInsert on ledger-entry-like tables) can multiply a small per-call cost into a system-wide regression.
|
|
||||||
|
|
||||||
## Best Practice
|
|
||||||
|
|
||||||
Keep subscribers small: guard early with inexpensive checks on the publisher record before doing any database work, defer heavy work to a task queue or a background session, and cache results across invocations when the data is stable. In hot events, a cheap `Type`/`Status`/`IsTemporary` exit before a `Get` or `FindFirst` is often the difference between a rare lookup and an N+1 query across every posted line.
|
|
||||||
|
|
||||||
See sample: `keep-event-subscribers-lightweight.good.al`.
|
|
||||||
|
|
||||||
## Anti Pattern
|
|
||||||
|
|
||||||
Calling an external web service, running a report, or iterating a large table from inside an event subscriber on a hot publisher makes every operation on that publisher as slow as the heaviest subscriber.
|
|
||||||
|
|
||||||
See sample: `keep-event-subscribers-lightweight.bad.al`.
|
|
||||||
|
|
||||||
|
|
@ -1,26 +0,0 @@
|
||||||
---
|
|
||||||
bc-version: [all]
|
|
||||||
domain: performance
|
|
||||||
keywords: [oncompanyopen, oncompanyopencompleted, session, sign-in, subscriber, startup]
|
|
||||||
technologies: [al]
|
|
||||||
countries: [w1]
|
|
||||||
application-area: [all]
|
|
||||||
---
|
|
||||||
|
|
||||||
# Keep OnCompanyOpen and OnCompanyOpenCompleted subscribers lightweight
|
|
||||||
|
|
||||||
## Description
|
|
||||||
|
|
||||||
`OnCompanyOpen` and `OnCompanyOpenCompleted` are raised every time a session is created — not only for interactive sign-ins, but also for every web service call, every job queue entry, every scheduled task, and every page background task. The session cannot run any AL code until every subscriber on these events has finished. Interactive users see a spinner; web service callers see elevated response times; background sessions sit idle waiting to start.
|
|
||||||
|
|
||||||
Anything expensive in these subscribers is paid per session across the whole tenant. The two patterns that typically cause production incidents are outgoing HTTP calls to external services — which block AL execution until they complete (or time out) — and long-running SQL over large tables. An external service that is slow or unreachable turns into a tenant-wide sign-in outage, not a degraded feature.
|
|
||||||
|
|
||||||
The code often looks harmless in review: a telemetry ping, a configuration refresh, a "just make sure the setup record exists" Get-or-Insert. Multiplied by session creations per minute, each of these becomes the critical path of sign-in.
|
|
||||||
|
|
||||||
## Best Practice
|
|
||||||
|
|
||||||
Keep `OnCompanyOpen` and `OnCompanyOpenCompleted` subscribers short and in-memory. Defer work that touches external services or large tables to a Page Background Task, a job queue entry, or a lazy first-use path. If an outgoing HTTP call in startup is truly unavoidable, set an aggressive timeout so a failing endpoint cannot stall session creation.
|
|
||||||
|
|
||||||
## Anti Pattern
|
|
||||||
|
|
||||||
An `OnCompanyOpen` subscriber that calls an external licensing API over HttpClient without a tight timeout. When the endpoint is slow, every new session in the tenant — UI, API, background — waits on the HTTP call before it can run any AL.
|
|
||||||
|
|
@ -1,22 +0,0 @@
|
||||||
---
|
|
||||||
bc-version: [all]
|
|
||||||
domain: performance
|
|
||||||
keywords: [sourcetabletemporary, tabletype, temporary, api-page, persistence, regression]
|
|
||||||
technologies: [al]
|
|
||||||
countries: [w1]
|
|
||||||
application-area: [all]
|
|
||||||
---
|
|
||||||
|
|
||||||
# Do not remove SourceTableTemporary or TableType = Temporary without understanding the impact
|
|
||||||
|
|
||||||
## Description
|
|
||||||
|
|
||||||
`SourceTableTemporary = true` on a page, and `TableType = Temporary` on a table, mean the underlying record operates in memory — Insert/Modify/Delete mutate the session buffer, not the database. Removing either property converts the same operations to real SQL writes. On an API page that external callers hit at high frequency, on a background task that processes thousands of records, or on a UI page that composes an in-memory list for display, the change from temporary to persistent can turn a lightweight operation into a major source of database load. The refactor is easy to propose ("why is this temporary?") and expensive to regret.
|
|
||||||
|
|
||||||
## Best Practice
|
|
||||||
|
|
||||||
When a diff removes `SourceTableTemporary = true` or `TableType = Temporary`, require justification explaining why persistence is now required and what paths still write. Review the callers for unexpected new writes, transaction scope, trigger fires, and contention. Keep the property unless the change genuinely needs persistence; an unused-looking temporary table on a bounded page is usually there for a reason.
|
|
||||||
|
|
||||||
## Anti Pattern
|
|
||||||
|
|
||||||
A cleanup PR that deletes `SourceTableTemporary = true` from an API page "because the source table already exists". The API now writes to the real table on every call, every consumer's requests reach the database, and the incidental side-effects in the source table's triggers start firing across tenants.
|
|
||||||
|
|
@ -1,24 +0,0 @@
|
||||||
---
|
|
||||||
bc-version: [all]
|
|
||||||
domain: performance
|
|
||||||
keywords: [locktable, updlock, transaction, contention, scope]
|
|
||||||
technologies: [al]
|
|
||||||
countries: [w1]
|
|
||||||
application-area: [all]
|
|
||||||
---
|
|
||||||
|
|
||||||
# LockTable applies to the whole table for the rest of the transaction
|
|
||||||
|
|
||||||
## Description
|
|
||||||
|
|
||||||
`Record.LockTable` is commonly read as "lock this record variable", but it does not work that way. The call applies `WITH (UPDLOCK)` to every subsequent read against the underlying table in the current transaction, regardless of which record variable issues the read. If `ItemA.LockTable` runs, then an unrelated `ItemB` variable on `Item`, a `FindSet` from a helper codeunit on `Item`, and any nested code that reads `Item` all acquire UPDLOCK until the transaction commits.
|
|
||||||
|
|
||||||
The consequence is that calling LockTable early in a transaction — for example at the top of a routine "to be safe" — upgrades every read of that table for the remainder of the transaction to a writer-blocking lock. Contention scales with transaction length, not with how many writes the code actually performs. A LockTable deep in a call graph can silently serialize readers that never touch the LockTable-ing variable.
|
|
||||||
|
|
||||||
## Best Practice
|
|
||||||
|
|
||||||
Defer `LockTable` as late as possible and place it as close to the actual modification as you can. Keep transactions short so the UPDLOCK window is narrow. Do not add LockTable preemptively to "protect" a read that is not part of a read-modify-write sequence — the correct tool for read consistency is an isolation level (see Record.ReadIsolation), not a write lock.
|
|
||||||
|
|
||||||
## Anti Pattern
|
|
||||||
|
|
||||||
A procedure that calls `Rec.LockTable()` at the start "before doing anything" and then performs a long read-heavy validation before the eventual Modify. Every read in the validation now takes UPDLOCK on the whole table, and every other session that tries to read the same table waits on this transaction.
|
|
||||||
|
|
@ -0,0 +1,26 @@
|
||||||
|
table 50227 "Perf Sample FA Journal Tmpl"
|
||||||
|
{
|
||||||
|
fields
|
||||||
|
{
|
||||||
|
field(1; Name; Code[10]) { }
|
||||||
|
field(40; "No. of Lines"; Integer)
|
||||||
|
{
|
||||||
|
FieldClass = FlowField;
|
||||||
|
// Source key below has MaintainSQLIndex = false: SIFT cannot
|
||||||
|
// function, so this COUNT runs without a SQL index.
|
||||||
|
CalcFormula = count("FA Journal Line"
|
||||||
|
where("Journal Template Name" = field(Name)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
tableextension 50228 "Perf Sample FA Jnl Line Ext" extends "FA Journal Line"
|
||||||
|
{
|
||||||
|
keys
|
||||||
|
{
|
||||||
|
key(PerfSampleByTemplate; "Journal Template Name", "Journal Batch Name")
|
||||||
|
{
|
||||||
|
MaintainSQLIndex = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,24 @@
|
||||||
|
---
|
||||||
|
bc-version: [all]
|
||||||
|
domain: performance
|
||||||
|
keywords: [maintainsqlindex, key, sift, flowfield, sum, count, table-scan]
|
||||||
|
technologies: [al]
|
||||||
|
countries: [w1]
|
||||||
|
application-area: [all]
|
||||||
|
---
|
||||||
|
|
||||||
|
# MaintainSQLIndex = false on a key disables SIFT for FlowFields that depend on it
|
||||||
|
|
||||||
|
## Description
|
||||||
|
|
||||||
|
`MaintainSQLIndex = false` on a key tells the platform not to materialize that key as a SQL index. Per the upstream guidance, when a FlowField's source key carries that property, "SIFT cannot function, COUNT/SUM will table-scan." The flag is sometimes set to save write-path cost on a rarely-queried key, but if a `CalcFormula` aggregates through that exact key, the FlowField loses its index — every `CalcFields`/`CalcSums`/list-page filter that triggers it runs without one.
|
||||||
|
|
||||||
|
## Best Practice
|
||||||
|
|
||||||
|
When changing a key property to `MaintainSQLIndex = false`, find every FlowField whose `CalcFormula` filters on that key and verify another key covers the same fields. When adding a FlowField whose source table has only a `MaintainSQLIndex = false` key for its filter columns, add a fully-indexed key (or accept that the FlowField cannot ride SIFT and reshape the design — see `flowfield-source-key-needs-sumindexfields.md`).
|
||||||
|
|
||||||
|
See sample: `maintainsqlindex-false-breaks-flowfield-sift.bad.al`.
|
||||||
|
|
||||||
|
## Anti Pattern
|
||||||
|
|
||||||
|
A FlowField whose `CalcFormula`'s `WHERE` columns line up with a key that has `MaintainSQLIndex = false`. The schema looks correct — the key exists, the SumIndexFields are listed — but at runtime the platform has no SQL index to use, and the aggregation table-scans on every invocation.
|
||||||
|
|
@ -1,22 +0,0 @@
|
||||||
---
|
|
||||||
bc-version: [all]
|
|
||||||
domain: performance
|
|
||||||
keywords: [maintainsqlindex, sift, sumindexfields, flowfield, calcsums, key]
|
|
||||||
technologies: [al]
|
|
||||||
countries: [w1]
|
|
||||||
application-area: [all]
|
|
||||||
---
|
|
||||||
|
|
||||||
# MaintainSQLIndex = false on a key disables SIFT for the FlowFields that depend on it
|
|
||||||
|
|
||||||
## Description
|
|
||||||
|
|
||||||
SIFT relies on the underlying SQL index being maintained by the platform. Setting `MaintainSQLIndex = false` on a key drops the SQL index without dropping the AL key declaration — the key compiles, FlowFields that reference its SumIndexFields compile, and CalcSums calls against matching filters compile. At runtime, however, the SIFT optimization silently cannot engage, and every aggregate falls back to a table scan. The symptom is a FlowField whose read time degrades linearly with row count, with no code-level signal pointing at the key property as the cause.
|
|
||||||
|
|
||||||
## Best Practice
|
|
||||||
|
|
||||||
Keep `MaintainSQLIndex = true` (the default) on any key whose SumIndexFields back a FlowField or that callers use with CalcSums. When a key is genuinely unused and the SQL index cost is the concern, remove the key entirely rather than leaving it in place with `MaintainSQLIndex = false`. If the FlowField is still needed, pick a different key that is maintained.
|
|
||||||
|
|
||||||
## Anti Pattern
|
|
||||||
|
|
||||||
A source-table key declared with `SumIndexFields` and `MaintainSQLIndex = false`, with a FlowField referencing those sum fields. The FlowField appears to work in development against small datasets and becomes a full table scan on production-scale data, with no error message and no obvious culprit in the code under review.
|
|
||||||
|
|
@ -1,10 +0,0 @@
|
||||||
codeunit 50107 "Perf Sample OnlyFetchUsed Bad"
|
|
||||||
{
|
|
||||||
procedure CustomerHasEntries(CustomerNo: Code[20]): Boolean
|
|
||||||
var
|
|
||||||
CustLedgerEntry: Record "Cust. Ledger Entry";
|
|
||||||
begin
|
|
||||||
CustLedgerEntry.SetRange("Customer No.", CustomerNo);
|
|
||||||
exit(CustLedgerEntry.FindSet());
|
|
||||||
end;
|
|
||||||
}
|
|
||||||
|
|
@ -1,10 +0,0 @@
|
||||||
codeunit 50106 "Perf Sample OnlyFetchUsed Good"
|
|
||||||
{
|
|
||||||
procedure CustomerHasEntries(CustomerNo: Code[20]): Boolean
|
|
||||||
var
|
|
||||||
CustLedgerEntry: Record "Cust. Ledger Entry";
|
|
||||||
begin
|
|
||||||
CustLedgerEntry.SetRange("Customer No.", CustomerNo);
|
|
||||||
exit(not CustLedgerEntry.IsEmpty());
|
|
||||||
end;
|
|
||||||
}
|
|
||||||
|
|
@ -1,29 +0,0 @@
|
||||||
---
|
|
||||||
bc-version: [all]
|
|
||||||
domain: performance
|
|
||||||
keywords: [findset, get, aa0175, wasted-fetch, read]
|
|
||||||
technologies: [al]
|
|
||||||
countries: [w1]
|
|
||||||
application-area: [all]
|
|
||||||
---
|
|
||||||
|
|
||||||
# Only fetch records you use
|
|
||||||
|
|
||||||
> Contributions welcome — open a PR to refine or extend this article.
|
|
||||||
|
|
||||||
## Description
|
|
||||||
|
|
||||||
CodeCop rule AA0175 flags code that retrieves a record and then does not use it. Every Find, FindSet, FindFirst, FindLast, or Get has a cost: the platform reads rows from SQL, materializes them, and transports them to the AL runtime. A call whose result is never read is wasted work, and on hot tables that work is never free.
|
|
||||||
|
|
||||||
## Best Practice
|
|
||||||
|
|
||||||
Retrieve a record only when you need one or more of its field values. When you only need to know whether at least one row matches a filter, use IsEmpty (see use-isempty-for-existence-checks). When you only need a subset of fields, use SetLoadFields (see use-setloadfields-for-partial-records).
|
|
||||||
|
|
||||||
See sample: `only-fetch-records-you-use.good.al`.
|
|
||||||
|
|
||||||
## Anti Pattern
|
|
||||||
|
|
||||||
Calling FindSet or Get and then ignoring the result, or using it only as a boolean existence test, performs the full fetch and throws the data away.
|
|
||||||
|
|
||||||
See sample: `only-fetch-records-you-use.bad.al`.
|
|
||||||
|
|
||||||
|
|
@ -0,0 +1,13 @@
|
||||||
|
codeunit 50209 "Perf Sample FindSetNext Bad"
|
||||||
|
{
|
||||||
|
procedure SumCustomerBalances() Total: Decimal
|
||||||
|
var
|
||||||
|
Customer: Record Customer;
|
||||||
|
begin
|
||||||
|
// AA0233: FindFirst paired with Next — single-row API used to iterate.
|
||||||
|
if Customer.FindFirst() then
|
||||||
|
repeat
|
||||||
|
Total += Customer."Balance (LCY)";
|
||||||
|
until Customer.Next() = 0;
|
||||||
|
end;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,18 @@
|
||||||
|
codeunit 50208 "Perf Sample FindSetNext Good"
|
||||||
|
{
|
||||||
|
procedure SumCustomerBalances() Total: Decimal
|
||||||
|
var
|
||||||
|
Customer: Record Customer;
|
||||||
|
begin
|
||||||
|
if Customer.FindSet() then
|
||||||
|
repeat
|
||||||
|
Total += Customer."Balance (LCY)";
|
||||||
|
until Customer.Next() = 0;
|
||||||
|
end;
|
||||||
|
|
||||||
|
procedure GetFirstUSCustomer(var Customer: Record Customer): Boolean
|
||||||
|
begin
|
||||||
|
Customer.SetRange("Country/Region Code", 'US');
|
||||||
|
exit(Customer.FindFirst());
|
||||||
|
end;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,26 @@
|
||||||
|
---
|
||||||
|
bc-version: [all]
|
||||||
|
domain: performance
|
||||||
|
keywords: [findset, findfirst, findlast, get, next, repeat-until, aa0181, aa0233]
|
||||||
|
technologies: [al]
|
||||||
|
countries: [w1]
|
||||||
|
application-area: [all]
|
||||||
|
---
|
||||||
|
|
||||||
|
# Use FindSet with repeat..Next; do not pair FindFirst/FindLast/Get with Next
|
||||||
|
|
||||||
|
## Description
|
||||||
|
|
||||||
|
Two CodeCop rules carve out the loop pattern. AA0181 says `FindSet()`/`Find()` "must be used with `Next()` method" — these are the multi-row APIs that the runtime sets up for forward iteration. AA0233 says do "NOT use `FindFirst()`/`FindLast()`/`Get()` with `Next()`" — these are single-row APIs, and iterating from them "wastes CPU and bandwidth." Both rules together define one boundary: choose `FindSet` when the body iterates; choose `FindFirst`, `FindLast`, or `Get` when the body uses exactly one record.
|
||||||
|
|
||||||
|
## Best Practice
|
||||||
|
|
||||||
|
When the body executes `repeat ... until Next() = 0;`, open the iteration with `FindSet()`. When the body needs one record and does not call `Next`, use `FindFirst`, `FindLast`, or — if the full primary key is known — `Get` (see `use-get-instead-of-findfirst-on-full-primary-key.md`). The choice is per call site, not a global preference.
|
||||||
|
|
||||||
|
See sample: `pair-findset-with-next-loop.good.al`.
|
||||||
|
|
||||||
|
## Anti Pattern
|
||||||
|
|
||||||
|
`if Customer.FindFirst() then repeat ... until Customer.Next() = 0;` — AA0233 flags this. The single-row API does not prepare the runtime for iteration, so the loop pays a cost the FindSet path does not. The mirror anti-pattern is calling `FindSet` to read a single record (see `use-isempty-for-existence-check.md` when only existence is required).
|
||||||
|
|
||||||
|
See sample: `pair-findset-with-next-loop.bad.al`.
|
||||||
|
|
@ -0,0 +1,21 @@
|
||||||
|
codeunit 50240 "Perf Sample Trigger Param Good"
|
||||||
|
{
|
||||||
|
procedure BulkFlagOrders(var SalesHeader: Record "Sales Header")
|
||||||
|
begin
|
||||||
|
if SalesHeader.FindSet(true) then
|
||||||
|
repeat
|
||||||
|
SalesHeader."Job Queue Status" := SalesHeader."Job Queue Status"::"Scheduled for Posting";
|
||||||
|
// Trigger has nothing to add for a status flip in this code path.
|
||||||
|
SalesHeader.Modify(false);
|
||||||
|
until SalesHeader.Next() = 0;
|
||||||
|
end;
|
||||||
|
|
||||||
|
procedure CreateOrder(var SalesHeader: Record "Sales Header"; CustomerNo: Code[20])
|
||||||
|
begin
|
||||||
|
SalesHeader.Init();
|
||||||
|
SalesHeader."Document Type" := SalesHeader."Document Type"::Order;
|
||||||
|
SalesHeader."Sell-to Customer No." := CustomerNo;
|
||||||
|
// OnInsert allocates the No.-Series number — the trigger is required.
|
||||||
|
SalesHeader.Insert(true);
|
||||||
|
end;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,24 @@
|
||||||
|
---
|
||||||
|
bc-version: [all]
|
||||||
|
domain: performance
|
||||||
|
keywords: [insert, modify, delete, trigger, run-trigger, write-parameters]
|
||||||
|
technologies: [al]
|
||||||
|
countries: [w1]
|
||||||
|
application-area: [all]
|
||||||
|
---
|
||||||
|
|
||||||
|
# Pass false to Insert/Modify/Delete when the table triggers do not need to fire
|
||||||
|
|
||||||
|
## Description
|
||||||
|
|
||||||
|
`Insert(true)`, `Modify(true)`, and `Delete(true)` run the table's `OnInsert`/`OnModify`/`OnDelete` trigger; the `(false)` form skips it. Per the upstream guidance, the trigger form should be used "only when needed" — every row whose write fires a trigger pays that cost, even when the trigger has nothing useful to add for the current call site. For tight bulk write paths the difference compounds linearly with row count.
|
||||||
|
|
||||||
|
## Best Practice
|
||||||
|
|
||||||
|
Reach for the `(false)` form when the calling code already enforces the invariants the trigger would, or when the trigger is empty for the current table/extension. Use `(true)` when the trigger does work the caller depends on (number-series allocation, validation, cascading writes). Decide per call, not by code style: a default of "always `true`" makes bulk writes pay for triggers they did not need, and a default of "always `false`" silently skips validation the trigger was put there to enforce.
|
||||||
|
|
||||||
|
See sample: `pass-false-to-insert-when-trigger-not-needed.good.al`.
|
||||||
|
|
||||||
|
## Anti Pattern
|
||||||
|
|
||||||
|
Looping over thousands of rows and calling `Modify(true)` on each, when the table's `OnModify` trigger does nothing relevant for the operation. The trigger cost is paid per row; the user-visible behavior is identical to the `(false)` form. The mirror is using `(false)` for an operation that depends on trigger-side defaulting and silently producing rows that fail downstream validation.
|
||||||
|
|
@ -0,0 +1,22 @@
|
||||||
|
---
|
||||||
|
bc-version: [all]
|
||||||
|
domain: performance
|
||||||
|
keywords: [dictionary, temporary-table, lookup, o-of-1, key-lookup]
|
||||||
|
technologies: [al]
|
||||||
|
countries: [w1]
|
||||||
|
application-area: [all]
|
||||||
|
---
|
||||||
|
|
||||||
|
# Prefer a Dictionary over a temporary table for pure lookups
|
||||||
|
|
||||||
|
## Description
|
||||||
|
|
||||||
|
A temporary table supports a full record API — filters, iteration, multi-field keys — but a pure key→value lookup pays for plumbing it does not use. Per the upstream guidance, "if a temporary table record is ONLY used as a lookup table, it is faster to use a dictionary which supports O(1) lookups instead of O(lg n) for temporary tables." The Dictionary type has no record machinery to traverse; the key hash answers the lookup directly.
|
||||||
|
|
||||||
|
## Best Practice
|
||||||
|
|
||||||
|
When the use of a temp record is "set a key, see if the row exists, read a single value", switch to `Dictionary of [Key, Value]`. Use the temp-table form when the use genuinely needs filtering, iteration in a specific order, or a multi-field key. Compatibility with code that expects a `Record` parameter is a real reason to keep the temp table; performance alone, on a pure lookup, is not.
|
||||||
|
|
||||||
|
## Anti Pattern
|
||||||
|
|
||||||
|
A temp `Record` declared, populated row by row, then queried with `SetRange(KeyField, X); if Find('=') then Value := Rec.ValueField;`. The lookup hashes the key behind the scenes and does the same work a `Dictionary` would, plus the per-row record overhead. The pattern often appears because the author originally needed iteration and the iteration was later removed without revisiting the data structure.
|
||||||
|
|
@ -1,20 +0,0 @@
|
||||||
codeunit 50135 "Perf Sample RecordRef Bad"
|
|
||||||
{
|
|
||||||
procedure BlockCustomer(CustomerNo: Code[20])
|
|
||||||
var
|
|
||||||
RecRef: RecordRef;
|
|
||||||
PkRef: KeyRef;
|
|
||||||
NoRef: FieldRef;
|
|
||||||
BlockedRef: FieldRef;
|
|
||||||
begin
|
|
||||||
RecRef.Open(Database::Customer);
|
|
||||||
PkRef := RecRef.KeyIndex(1);
|
|
||||||
NoRef := PkRef.FieldIndex(1);
|
|
||||||
NoRef.SetRange(CustomerNo);
|
|
||||||
if not RecRef.FindFirst() then
|
|
||||||
exit;
|
|
||||||
BlockedRef := RecRef.Field(54);
|
|
||||||
BlockedRef.Value(2);
|
|
||||||
RecRef.Modify(true);
|
|
||||||
end;
|
|
||||||
}
|
|
||||||
|
|
@ -1,12 +0,0 @@
|
||||||
codeunit 50134 "Perf Sample RecordRef Good"
|
|
||||||
{
|
|
||||||
procedure BlockCustomer(CustomerNo: Code[20])
|
|
||||||
var
|
|
||||||
Customer: Record Customer;
|
|
||||||
begin
|
|
||||||
if not Customer.Get(CustomerNo) then
|
|
||||||
exit;
|
|
||||||
Customer.Blocked := Customer.Blocked::All;
|
|
||||||
Customer.Modify(true);
|
|
||||||
end;
|
|
||||||
}
|
|
||||||
|
|
@ -1,31 +0,0 @@
|
||||||
---
|
|
||||||
bc-version: [all]
|
|
||||||
domain: performance
|
|
||||||
keywords: [recordref, fieldref, dynamic, reflection]
|
|
||||||
technologies: [al]
|
|
||||||
countries: [w1]
|
|
||||||
application-area: [all]
|
|
||||||
---
|
|
||||||
|
|
||||||
# Prefer direct record access over RecordRef where possible
|
|
||||||
|
|
||||||
> Contributions welcome — open a PR to refine or extend this article.
|
|
||||||
|
|
||||||
## Description
|
|
||||||
|
|
||||||
RecordRef and FieldRef are the platform's reflection API: they work across tables the compiler does not know at authoring time. That flexibility costs per-operation overhead — every field access goes through a lookup — and loses compile-time type checking. For operations where the table is known, a strongly-typed Record variable is simpler and faster.
|
|
||||||
|
|
||||||
## Best Practice
|
|
||||||
|
|
||||||
Use Record variables for code paths that target a known table. Reach for RecordRef and FieldRef only when the table is genuinely dynamic (generic export/import, field-agnostic utilities, cross-table integrations).
|
|
||||||
|
|
||||||
Only flag RecordRef usage as a performance concern when it appears inside a **hot, unbounded loop** — typically iterating over ledger-entry-scale tables (10,000+ rows) — where a strongly-typed Record alternative exists. RecordRef in bounded contexts, one-off operations, admin tools, setup helpers, or wizard code is not a performance concern and should not be flagged.
|
|
||||||
|
|
||||||
See sample: `prefer-direct-record-over-recordref.good.al`.
|
|
||||||
|
|
||||||
## Anti Pattern
|
|
||||||
|
|
||||||
Using RecordRef as a habit, even when the target table is hardcoded two lines earlier, costs performance and hides intent from reviewers.
|
|
||||||
|
|
||||||
See sample: `prefer-direct-record-over-recordref.bad.al`.
|
|
||||||
|
|
||||||
|
|
@ -1,10 +0,0 @@
|
||||||
codeunit 50130 "Perf Sample GetVsFind Good"
|
|
||||||
{
|
|
||||||
procedure CustomerName(CustomerNo: Code[20]): Text[100]
|
|
||||||
var
|
|
||||||
Customer: Record Customer;
|
|
||||||
begin
|
|
||||||
if Customer.Get(CustomerNo) then
|
|
||||||
exit(Customer.Name);
|
|
||||||
end;
|
|
||||||
}
|
|
||||||
|
|
@ -1,29 +0,0 @@
|
||||||
---
|
|
||||||
bc-version: [all]
|
|
||||||
domain: performance
|
|
||||||
keywords: [get, findfirst, primary-key, lookup]
|
|
||||||
technologies: [al]
|
|
||||||
countries: [w1]
|
|
||||||
application-area: [all]
|
|
||||||
---
|
|
||||||
|
|
||||||
# Prefer Get for primary-key lookups
|
|
||||||
|
|
||||||
> Contributions welcome — open a PR to refine or extend this article.
|
|
||||||
|
|
||||||
## Description
|
|
||||||
|
|
||||||
Get is a direct primary-key lookup: one index seek, one row, done. FindFirst with SetRange on the primary key fields reaches the same row through a more general code path and carries the overhead of filter setup and a broader optimizer decision.
|
|
||||||
|
|
||||||
## Best Practice
|
|
||||||
|
|
||||||
When the complete primary key is known, call Get. Use FindFirst only for non-primary-key lookups or when the filter is a partial prefix of the key.
|
|
||||||
|
|
||||||
See sample: `prefer-get-for-primary-key-lookups.good.al`.
|
|
||||||
|
|
||||||
## Anti Pattern
|
|
||||||
|
|
||||||
Setting one SetRange per primary-key field and then calling FindFirst reproduces Get with more typing and slightly worse performance.
|
|
||||||
|
|
||||||
See sample: `prefer-get-for-primary-key-lookups.bad.al`.
|
|
||||||
|
|
||||||
|
|
@ -0,0 +1,15 @@
|
||||||
|
codeunit 50243 "Perf Sample ModifyAll Bad"
|
||||||
|
{
|
||||||
|
procedure ApplyPriceUpdate(NewPrice: Decimal)
|
||||||
|
var
|
||||||
|
SalesLine: Record "Sales Line";
|
||||||
|
begin
|
||||||
|
SalesLine.SetRange(Type, SalesLine.Type::Item);
|
||||||
|
// N writes when one ModifyAll would do.
|
||||||
|
if SalesLine.FindSet() then
|
||||||
|
repeat
|
||||||
|
SalesLine.Validate("Unit Price", NewPrice);
|
||||||
|
SalesLine.Modify(true);
|
||||||
|
until SalesLine.Next() = 0;
|
||||||
|
end;
|
||||||
|
}
|
||||||
|
|
@ -1,12 +1,19 @@
|
||||||
codeunit 51207 "Perf Sample CombineMA Bad"
|
codeunit 50242 "Perf Sample ModifyAll Good"
|
||||||
{
|
{
|
||||||
procedure UpdateTolerance(DocumentNo: Code[20]; ToleranceAmount: Decimal)
|
procedure ApplyPriceUpdate(NewPrice: Decimal)
|
||||||
|
var
|
||||||
|
SalesLine: Record "Sales Line";
|
||||||
|
begin
|
||||||
|
SalesLine.SetRange(Type, SalesLine.Type::Item);
|
||||||
|
SalesLine.ModifyAll("Unit Price", NewPrice);
|
||||||
|
end;
|
||||||
|
|
||||||
|
procedure ApplyTolerance(DocumentNo: Code[20]; ToleranceAmount: Decimal)
|
||||||
var
|
var
|
||||||
CustLedgerEntry: Record "Cust. Ledger Entry";
|
CustLedgerEntry: Record "Cust. Ledger Entry";
|
||||||
begin
|
begin
|
||||||
CustLedgerEntry.SetRange("Document No.", DocumentNo);
|
CustLedgerEntry.SetRange("Document No.", DocumentNo);
|
||||||
CustLedgerEntry.SetRange(Open, true);
|
CustLedgerEntry.SetRange(Open, true);
|
||||||
// Two scans over the same filtered rows on a 10M-row ledger table.
|
|
||||||
CustLedgerEntry.ModifyAll("Accepted Payment Tolerance", ToleranceAmount);
|
CustLedgerEntry.ModifyAll("Accepted Payment Tolerance", ToleranceAmount);
|
||||||
CustLedgerEntry.ModifyAll("Accepted Pmt. Disc. Tolerance", false);
|
CustLedgerEntry.ModifyAll("Accepted Pmt. Disc. Tolerance", false);
|
||||||
end;
|
end;
|
||||||
|
|
@ -0,0 +1,26 @@
|
||||||
|
---
|
||||||
|
bc-version: [all]
|
||||||
|
domain: performance
|
||||||
|
keywords: [modifyall, deleteall, bulk, loop, modify, set-based]
|
||||||
|
technologies: [al]
|
||||||
|
countries: [w1]
|
||||||
|
application-area: [all]
|
||||||
|
---
|
||||||
|
|
||||||
|
# Use ModifyAll / DeleteAll instead of per-row Modify / Delete in a loop
|
||||||
|
|
||||||
|
## Description
|
||||||
|
|
||||||
|
`ModifyAll` and `DeleteAll` are the bulk APIs. Per the upstream guidance, they "execute as single SQL statements" when the table supports it — one round-trip updates or deletes every row in the filtered set. The anti-pattern is the loop equivalent: `FindSet` followed by per-row `Modify`/`Delete`, where the runtime issues one write per row. On a production-scale table the difference is the difference between a single statement and N statements.
|
||||||
|
|
||||||
|
## Best Practice
|
||||||
|
|
||||||
|
When the loop body does nothing more than assign a constant value (or a value computed once) to one or more fields, replace the loop with `ModifyAll("Field 1", Value1)` — and chain additional `ModifyAll` calls for additional fields. The same shape applies to `DeleteAll`. Be aware that the bulk APIs can regress to row-by-row execution for tables with certain trigger or media-field configurations (see `triggers-and-media-field-regress-modifyall.md`); when that regression applies, multiple `ModifyAll` calls become more expensive than one manual loop, so the choice is conditional, not absolute.
|
||||||
|
|
||||||
|
See sample: `prefer-modifyall-over-per-row-modify.good.al`.
|
||||||
|
|
||||||
|
## Anti Pattern
|
||||||
|
|
||||||
|
`if SalesLine.FindSet() then repeat SalesLine.Validate("Unit Price", NewPrice); SalesLine.Modify(true); until SalesLine.Next() = 0;` — N writes when one would do. The pattern is easy to introduce when the loop initially does per-row computation and is later simplified to assign a constant; the loop scaffolding survives the simplification.
|
||||||
|
|
||||||
|
See sample: `prefer-modifyall-over-per-row-modify.bad.al`.
|
||||||
|
|
@ -0,0 +1,13 @@
|
||||||
|
codeunit 50233 "Perf Sample ReadIso Bad"
|
||||||
|
{
|
||||||
|
procedure GetOrCreate(var AgentStatus: Record "Agent Status")
|
||||||
|
begin
|
||||||
|
// LockTable poisons every subsequent read of Agent Status in the
|
||||||
|
// surrounding transaction with UPDLOCK — even for callers that only read.
|
||||||
|
AgentStatus.LockTable();
|
||||||
|
if not AgentStatus.Get() then begin
|
||||||
|
AgentStatus.Init();
|
||||||
|
AgentStatus.Insert();
|
||||||
|
end;
|
||||||
|
end;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,11 @@
|
||||||
|
codeunit 50232 "Perf Sample ReadIso Good"
|
||||||
|
{
|
||||||
|
procedure GetOrCreate(var AgentStatus: Record "Agent Status")
|
||||||
|
begin
|
||||||
|
AgentStatus.ReadIsolation := IsolationLevel::ReadCommitted;
|
||||||
|
if not AgentStatus.Get() then begin
|
||||||
|
AgentStatus.Init();
|
||||||
|
AgentStatus.Insert();
|
||||||
|
end;
|
||||||
|
end;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,26 @@
|
||||||
|
---
|
||||||
|
bc-version: [all]
|
||||||
|
domain: performance
|
||||||
|
keywords: [readisolation, locktable, updlock, read-only, transaction-scope, isolation-level]
|
||||||
|
technologies: [al]
|
||||||
|
countries: [w1]
|
||||||
|
application-area: [all]
|
||||||
|
---
|
||||||
|
|
||||||
|
# Prefer ReadIsolation over LockTable for read-only scenarios
|
||||||
|
|
||||||
|
## Description
|
||||||
|
|
||||||
|
`LockTable` and `ReadIsolation` solve different problems with different blast radii. Per the upstream guidance, "`LockTable` ensures that all READS against that table will happen with UPDLOCK for the remainder of the transaction." `ReadIsolation` "only pertains to the current record instance, while `LockTable` affects the lockstate of the entire transaction." `ReadIsolation` is also more expressive: it can heighten or lower the isolation level inside an already-established transaction. Reaching for `LockTable` when only a single read needs guarding therefore poisons every later read on that table — including reads in other code paths that share the transaction.
|
||||||
|
|
||||||
|
## Best Practice
|
||||||
|
|
||||||
|
For a read-only operation, or a single read that needs a higher isolation level than the surrounding transaction, set `Rec.ReadIsolation := IsolationLevel::ReadCommitted;` (or the level the call requires) immediately before the read. The hint applies only to that record instance. Save `LockTable` for code that genuinely needs every subsequent read on the table to acquire an update lock (see `findset-true-applies-updlock-on-read.md` for the alternative narrower mechanism on iterated reads).
|
||||||
|
|
||||||
|
See sample: `prefer-readisolation-over-locktable-for-reads.good.al`.
|
||||||
|
|
||||||
|
## Anti Pattern
|
||||||
|
|
||||||
|
`Rec.LockTable();` at the top of a helper that only reads, perhaps to "make sure the read is consistent". Every subsequent read on that table for the rest of the transaction acquires `UPDLOCK`, including reads from unrelated code paths fused into the same transaction. The contention surfaces in unrelated user sessions, not in the helper that introduced it.
|
||||||
|
|
||||||
|
See sample: `prefer-readisolation-over-locktable-for-reads.bad.al`.
|
||||||
|
|
@ -0,0 +1,22 @@
|
||||||
|
---
|
||||||
|
bc-version: [all]
|
||||||
|
domain: performance
|
||||||
|
keywords: [table-size, hot-table, ledger-entry, item, customer, sales-line, scale]
|
||||||
|
technologies: [al]
|
||||||
|
countries: [w1]
|
||||||
|
application-area: [all]
|
||||||
|
---
|
||||||
|
|
||||||
|
# Production-scale tables warrant concrete performance analysis
|
||||||
|
|
||||||
|
## Description
|
||||||
|
|
||||||
|
Some Business Central tables routinely reach sizes where access patterns matter much more than they do on a generic table. The upstream review guidance lists ten of them with P95 row counts: Item (~800k), Customer (~800k), Item Ledger Entry (~10M), Value Entry (~10M), G/L Entry (~10M), VAT Entry (~10M), Customer Ledger Entry (~10M), Vendor Ledger Entry (~10M), Sales Invoice Header (~300k), and Sales Invoice Line (~3M). These figures are not platform constants — they are the volumes a reviewer should assume when judging a change.
|
||||||
|
|
||||||
|
## Best Practice
|
||||||
|
|
||||||
|
For any code change that touches one of these tables, do not approve the pattern on intuition. Walk through the SQL the change implies (one query? one per row? one per chunk?), the memory it allocates (a `List` per row?), and the CPU work per row, against the row counts above. Smaller tables can tolerate a sub-optimal access pattern; these cannot. The rest of this domain — `apply-filters-before-iterating.md`, `use-setloadfields-for-partial-records.md`, `avoid-calcfields-in-loops.md`, `pair-findset-with-next-loop.md`, `avoid-get-inside-loop-on-persistent-tables.md` — exists primarily so that code touching these tables stays on the safe side of each rule.
|
||||||
|
|
||||||
|
## Anti Pattern
|
||||||
|
|
||||||
|
Generalizing from a unit test or a development tenant. A `FindSet` loop with a per-row `CalcFields` may execute in milliseconds against a few thousand rows on a developer's machine and become a multi-minute table scan against ten million Value Entry rows in production. Reasoning about performance from the dev-tenant timing instead of the production volume is the single most common way a regression ships.
|
||||||
|
|
@ -1,26 +0,0 @@
|
||||||
---
|
|
||||||
bc-version: [all]
|
|
||||||
domain: performance
|
|
||||||
keywords: [query, cache, primary-key-cache, record-api, sql]
|
|
||||||
technologies: [al]
|
|
||||||
countries: [w1]
|
|
||||||
application-area: [all]
|
|
||||||
---
|
|
||||||
|
|
||||||
# Query objects bypass the primary-key cache and always hit SQL
|
|
||||||
|
|
||||||
## Description
|
|
||||||
|
|
||||||
The Record API reuses a server-side primary-key cache: repeated reads of the same rows within a session or request can be served from memory without going to SQL. Query objects do not participate in that cache. Every execution of a query goes to the database, even when the same rows were just read through a Record variable in the same transaction.
|
|
||||||
|
|
||||||
This inverts the usual intuition that queries are always faster than record loops. Queries win when they exploit a covering index, aggregate, or join multiple tables in SQL that AL would otherwise loop. They lose when the data is small, already cached, or read repeatedly in a short window — the per-call SQL round-trip dominates.
|
|
||||||
|
|
||||||
Query objects also cannot write, cannot be backed by a page, and do not see the records a temp-table-backed AL flow has inserted but not committed. Choose them for set-based reads over indexed data, not as a generic replacement for the Record API.
|
|
||||||
|
|
||||||
## Best Practice
|
|
||||||
|
|
||||||
Use a query object when the shape of the work is genuinely set-based: aggregation, multi-table join, or a large read that benefits from a covering index. For hot single-record or small-result reads — especially lookups that will repeat in the same request — prefer the Record API so the primary-key cache does its job.
|
|
||||||
|
|
||||||
## Anti Pattern
|
|
||||||
|
|
||||||
Replacing a `Get` or a short filtered `FindSet` inside a frequently-called helper with a query object "for performance". Every caller now pays a SQL round-trip that the Record API cache had been absorbing, and the helper gets slower under load, not faster.
|
|
||||||
|
|
@ -1,9 +0,0 @@
|
||||||
codeunit 50122 "Perf Sample SetCurrentKey Good"
|
|
||||||
{
|
|
||||||
procedure LinesForDocument(DocumentType: Enum "Sales Document Type"; DocumentNo: Code[20]; var SalesLine: Record "Sales Line")
|
|
||||||
begin
|
|
||||||
SalesLine.SetCurrentKey("Document Type", "Document No.", "Line No.");
|
|
||||||
SalesLine.SetRange("Document Type", DocumentType);
|
|
||||||
SalesLine.SetRange("Document No.", DocumentNo);
|
|
||||||
end;
|
|
||||||
}
|
|
||||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue