mirror of
https://github.com/microsoft/BCQuality.git
synced 2026-08-06 17:36:53 +01:00
Add per-row AL performance guidance (#97)
* Add per-row performance guidance Document SetAutoCalcFields for per-row FlowFields and direct writes on iterated records, with focused reviewer retrieval cues. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 85be3fc4-5253-47b8-ba1b-6b8fd188fcea * Bound commit checkpoints by key range Use a capped ordered query to discover each checkpoint watermark before locking and processing only that key range. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 85be3fc4-5253-47b8-ba1b-6b8fd188fcea * Address performance retrieval review Retrieve Commit-in-loop guidance precisely, process exact checkpoint key lists, and narrow clone-before-write discovery. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 85be3fc4-5253-47b8-ba1b-6b8fd188fcea --------- Co-authored-by: Jesper Schulz-Wedde <jesper.schulzwedde@microsoft.com> Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
parent
98af9aa1fc
commit
078b869e33
9 changed files with 174 additions and 18 deletions
|
|
@ -0,0 +1,19 @@
|
|||
codeunit 50493 "Perf Record Clone Bad"
|
||||
{
|
||||
procedure IncreaseCustomerCreditLimits(Percent: Decimal)
|
||||
var
|
||||
Customer: Record Customer;
|
||||
CustomerCopy: Record Customer;
|
||||
begin
|
||||
Customer.SetLoadFields("Credit Limit (LCY)");
|
||||
Customer.SetFilter("Credit Limit (LCY)", '>0');
|
||||
if Customer.FindSet(true) then
|
||||
repeat
|
||||
CustomerCopy.Copy(Customer);
|
||||
CustomerCopy.Validate(
|
||||
"Credit Limit (LCY)",
|
||||
Round(CustomerCopy."Credit Limit (LCY)" * (1 + Percent / 100)));
|
||||
CustomerCopy.Modify(true);
|
||||
until Customer.Next() = 0;
|
||||
end;
|
||||
}
|
||||
|
|
@ -0,0 +1,17 @@
|
|||
codeunit 50492 "Perf Record Clone Good"
|
||||
{
|
||||
procedure IncreaseCustomerCreditLimits(Percent: Decimal)
|
||||
var
|
||||
Customer: Record Customer;
|
||||
begin
|
||||
Customer.SetLoadFields("Credit Limit (LCY)");
|
||||
Customer.SetFilter("Credit Limit (LCY)", '>0');
|
||||
if Customer.FindSet(true) then
|
||||
repeat
|
||||
Customer.Validate(
|
||||
"Credit Limit (LCY)",
|
||||
Round(Customer."Credit Limit (LCY)" * (1 + Percent / 100)));
|
||||
Customer.Modify(true);
|
||||
until Customer.Next() = 0;
|
||||
end;
|
||||
}
|
||||
|
|
@ -0,0 +1,26 @@
|
|||
---
|
||||
bc-version: [all]
|
||||
domain: performance
|
||||
keywords: [clone, clone-before-write, copy, gettable, by-value, copied-record, writing-helper]
|
||||
technologies: [al]
|
||||
countries: [w1]
|
||||
application-area: [all]
|
||||
---
|
||||
|
||||
# Avoid cloning records before Modify or Delete in loops
|
||||
|
||||
## Description
|
||||
|
||||
Microsoft's [AL database-method performance guidance](https://learn.microsoft.com/en-us/dynamics365/business-central/dev-itpro/administration/optimize-sql-al-database-methods-and-performance-on-server#insert-modify-delete-and-locktable) states that cloning an iterated record before `Modify` or `Delete` restarts the SQL `SELECT` and issues an extra SQL statement for every row. The runtime treats `Record.Copy`, `RecordRef.GetTable`, and passing a record by value to a writing helper as clones in this situation.
|
||||
|
||||
## Best Practice
|
||||
|
||||
Use `FindSet(true)` when the loop writes the traversed rows, and call `Modify` or `Delete` on that iterating record variable. If generic code is required, open and iterate the `RecordRef` directly instead of calling `GetTable` for each typed record. Keep a per-row loop when validation or row-specific behavior is required; this rule does not imply that `ModifyAll` or `DeleteAll` is equivalent.
|
||||
|
||||
See sample: `avoid-cloning-records-before-modify-delete-in-loops.good.al`.
|
||||
|
||||
## Anti Pattern
|
||||
|
||||
Inside an active traversal, copy the current row, convert it with `RecordRef.GetTable`, or pass it without `var` to a helper, then call `Modify` or `Delete` on that clone. Do not flag read-only snapshots, temporary records, or copies used to write a different target table; the documented extra-statement concern is clone-before-write on the traversed table.
|
||||
|
||||
See sample: `avoid-cloning-records-before-modify-delete-in-loops.bad.al`.
|
||||
|
|
@ -1,3 +1,17 @@
|
|||
query 50127 "Perf Customer Chunk"
|
||||
{
|
||||
QueryType = Normal;
|
||||
OrderBy = ascending(CustomerNo);
|
||||
|
||||
elements
|
||||
{
|
||||
dataitem(Customer; Customer)
|
||||
{
|
||||
column(CustomerNo; "No.") { }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
codeunit 50128 "Perf Sample CommitInLoop Good"
|
||||
{
|
||||
procedure NormalizeCustomerNames()
|
||||
|
|
@ -5,28 +19,42 @@ codeunit 50128 "Perf Sample CommitInLoop Good"
|
|||
LastCustomerNo: Code[20];
|
||||
begin
|
||||
// The outer loop owns checkpoints; the per-row loop contains no Commit.
|
||||
while NormalizeNextChunk(LastCustomerNo, 500) do
|
||||
while NormalizeNextChunk(LastCustomerNo) do
|
||||
Commit();
|
||||
end;
|
||||
|
||||
local procedure NormalizeNextChunk(var LastCustomerNo: Code[20]; ChunkSize: Integer): Boolean
|
||||
local procedure NormalizeNextChunk(var LastCustomerNo: Code[20]): Boolean
|
||||
var
|
||||
Customer: Record Customer;
|
||||
RowsInChunk: Integer;
|
||||
TempCustomer: Record Customer temporary;
|
||||
CustomerChunk: Query "Perf Customer Chunk";
|
||||
LastChunkCustomerNo: Code[20];
|
||||
begin
|
||||
Customer.SetCurrentKey("No.");
|
||||
CustomerChunk.TopNumberOfRows(500);
|
||||
if LastCustomerNo <> '' then
|
||||
Customer.SetFilter("No.", '>%1', LastCustomerNo);
|
||||
if not Customer.FindSet(true) then
|
||||
CustomerChunk.SetFilter(CustomerNo, '>%1', LastCustomerNo);
|
||||
CustomerChunk.Open();
|
||||
while CustomerChunk.Read() do begin
|
||||
TempCustomer.Init();
|
||||
TempCustomer."No." := CustomerChunk.CustomerNo;
|
||||
TempCustomer.Insert();
|
||||
LastChunkCustomerNo := CustomerChunk.CustomerNo;
|
||||
end;
|
||||
CustomerChunk.Close();
|
||||
|
||||
if TempCustomer.IsEmpty() then
|
||||
exit(false);
|
||||
|
||||
repeat
|
||||
Customer.Name := UpperCase(Customer.Name);
|
||||
Customer.Modify();
|
||||
LastCustomerNo := Customer."No.";
|
||||
RowsInChunk += 1;
|
||||
until (RowsInChunk >= ChunkSize) or (Customer.Next() = 0);
|
||||
Customer.LockTable();
|
||||
if TempCustomer.FindSet() then
|
||||
repeat
|
||||
if Customer.Get(TempCustomer."No.") then begin
|
||||
Customer.Name := UpperCase(Customer.Name);
|
||||
Customer.Modify();
|
||||
end;
|
||||
until TempCustomer.Next() = 0;
|
||||
|
||||
LastCustomerNo := LastChunkCustomerNo;
|
||||
exit(true);
|
||||
end;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
---
|
||||
bc-version: [all]
|
||||
domain: performance
|
||||
keywords: [commit, loop, transaction, lock, checkpoint, codeunit-run]
|
||||
keywords: [commit, commit-in-loop, per-row-commit, checkpoint, bounded-checkpoint, watermark, topnumberofrows]
|
||||
technologies: [al]
|
||||
countries: [w1]
|
||||
application-area: [all]
|
||||
|
|
@ -13,16 +13,16 @@ application-area: [all]
|
|||
|
||||
## Description
|
||||
|
||||
Commit ends the current write transaction. Calling it inside a per-row loop produces one transaction per iteration and loses the ability to roll back the whole operation atomically; it also interferes with the platform's ability to batch write operations. Most loops need no explicit Commit at all — AL auto-commits the enclosing code module on successful completion (see `understand-implicit-transaction-boundary.md`). When the batch is too large for one transaction, the fix is not a per-row Commit but bounded checkpoints that each process N rows.
|
||||
Commit ends the current write transaction. Calling it inside a per-row loop produces one transaction per iteration and loses the ability to roll back the whole operation atomically; it also interferes with the platform's ability to batch write operations. Most loops need no explicit Commit at all — AL auto-commits the enclosing code module on successful completion (see `understand-implicit-transaction-boundary.md`). When the batch is too large for one transaction, the fix is not a per-row Commit but bounded checkpoints that select an exact list of at most N keys and process only those rows.
|
||||
|
||||
## Best Practice
|
||||
|
||||
If the batch is large enough that a single transaction is untenable, use an outer loop that selects and finishes the next N rows. Commit only after the inner row loop has returned and the checkpoint state identifies where the next chunk starts. A `Codeunit.Run` boundary can also own a chunk when its implicit commit and error behavior fit the caller — see `codeunit-run-as-atomic-sub-operation.md`.
|
||||
If the batch is large enough that a single transaction is untenable, use an ordered primary-key watermark and retrieve a bounded next-N key list. `FindSet` is optimized for reading the complete filtered set and isn't implemented as `TOP X`, so calling it over the remaining tail and breaking after N rows does not bound retrieval. The sample uses a query capped by [`TopNumberOfRows`](https://learn.microsoft.com/en-us/dynamics365/business-central/dev-itpro/developer/methods-auto/query/queryinstance-topnumberofrows-method) to fill a temporary key buffer, then takes update locks and modifies only those exact keys. It does not reconstruct an inclusive first-to-last range that concurrent inserts could expand. Commit after the bounded inner loop returns and persist its last selected key as the next watermark. Use a stable key and define how a later run handles records inserted at or below an already committed watermark. A `Codeunit.Run` boundary can also own a chunk when its implicit commit and error behavior fit the caller — see `codeunit-run-as-atomic-sub-operation.md`.
|
||||
|
||||
See sample: `avoid-commit-inside-loops.good.al`.
|
||||
|
||||
## Anti Pattern
|
||||
|
||||
Placing Commit inside `repeat ... until Next() = 0` is almost always a mistake: it is unusual for the correctness of the operation to depend on per-row commits, and the cost of starting a new transaction on every row dominates the work.
|
||||
Placing Commit inside `repeat ... until Next() = 0` is almost always a mistake: it is unusual for the correctness of the operation to depend on per-row commits, and the cost of starting a new transaction on every row dominates the work. A capped query that discovers only an upper key and then re-reads an inclusive key range is not exact batching either; concurrent inserts inside that range can enlarge the checkpoint.
|
||||
|
||||
See sample: `avoid-commit-inside-loops.bad.al`.
|
||||
|
|
|
|||
|
|
@ -0,0 +1,16 @@
|
|||
codeunit 50491 "Perf AutoCalcFields Bad"
|
||||
{
|
||||
procedure CollectOverLimitCustomers(var CustomerNos: List of [Code[20]])
|
||||
var
|
||||
Customer: Record Customer;
|
||||
begin
|
||||
Customer.SetLoadFields("Credit Limit (LCY)");
|
||||
Customer.SetFilter("Credit Limit (LCY)", '>0');
|
||||
if Customer.FindSet() then
|
||||
repeat
|
||||
Customer.CalcFields("Balance (LCY)");
|
||||
if Customer."Balance (LCY)" > Customer."Credit Limit (LCY)" then
|
||||
CustomerNos.Add(Customer."No.");
|
||||
until Customer.Next() = 0;
|
||||
end;
|
||||
}
|
||||
|
|
@ -0,0 +1,16 @@
|
|||
codeunit 50490 "Perf AutoCalcFields Good"
|
||||
{
|
||||
procedure CollectOverLimitCustomers(var CustomerNos: List of [Code[20]])
|
||||
var
|
||||
Customer: Record Customer;
|
||||
begin
|
||||
Customer.SetLoadFields("Credit Limit (LCY)");
|
||||
Customer.SetFilter("Credit Limit (LCY)", '>0');
|
||||
Customer.SetAutoCalcFields("Balance (LCY)");
|
||||
if Customer.FindSet() then
|
||||
repeat
|
||||
if Customer."Balance (LCY)" > Customer."Credit Limit (LCY)" then
|
||||
CustomerNos.Add(Customer."No.");
|
||||
until Customer.Next() = 0;
|
||||
end;
|
||||
}
|
||||
|
|
@ -0,0 +1,26 @@
|
|||
---
|
||||
bc-version: [all]
|
||||
domain: performance
|
||||
keywords: [setautocalcfields, calcfields, calcsums, flowfield, loop, per-row]
|
||||
technologies: [al]
|
||||
countries: [w1]
|
||||
application-area: [all]
|
||||
---
|
||||
|
||||
# Use SetAutoCalcFields when each iterated row needs a FlowField
|
||||
|
||||
## Description
|
||||
|
||||
`Record.SetAutoCalcFields` has been available since runtime 1.0 and makes the specified FlowFields calculate as records are retrieved. Microsoft's [AL database-method performance guidance](https://learn.microsoft.com/en-us/dynamics365/business-central/dev-itpro/administration/optimize-sql-al-database-methods-and-performance-on-server#setautocalcfields) uses it to remove an explicit `CalcFields` call from every iteration when each row's FlowField drives a branch. This is different from `CalcSums`, which returns a total for the filtered set rather than a value for each row.
|
||||
|
||||
## Best Practice
|
||||
|
||||
Call `SetAutoCalcFields` before `FindSet` when every returned row needs the same FlowField for a comparison, branch, or per-record action. Use `CalcSums` instead when the required result is one aggregate over the filtered set (see `calcsums-instead-of-calcfields-in-loop.md`).
|
||||
|
||||
See sample: `use-setautocalcfields-for-per-row-flowfields.good.al`.
|
||||
|
||||
## Anti Pattern
|
||||
|
||||
Calling `CalcFields` inside the loop when every iteration reads the same FlowField. Each `CalcFields` request requires a separate SQL statement unless a compatible recent result is cached. Do not replace row-specific decisions with `CalcSums`; an aggregate cannot preserve which rows met the condition.
|
||||
|
||||
See sample: `use-setautocalcfields-for-per-row-flowfields.bad.al`.
|
||||
Loading…
Add table
Add a link
Reference in a new issue