mirror of
https://github.com/microsoft/BCQuality.git
synced 2026-10-05 06:36:55 +01:00
Add BC performance knowledge from OptimAL learnings (#198)
* Add BC performance knowledge from OptimAL learnings Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> * Address review feedback on performance guidance Clarify predicate-supporting keys versus covering queries, demonstrate proven cache reuse, and evaluate the updated partial-load and bulk-update rules. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --------- 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
56ce52a9c7
commit
87ba36e650
37 changed files with 920 additions and 37 deletions
|
|
@ -0,0 +1,73 @@
|
|||
// Input stays stable for this run; output contains one row per group with entries.
|
||||
table 50367 "Perf Posted Entry"
|
||||
{
|
||||
DataClassification = CustomerContent;
|
||||
|
||||
fields
|
||||
{
|
||||
field(1; "Entry No."; Integer) { }
|
||||
field(2; "Item No."; Code[20]) { }
|
||||
field(3; "Location Code"; Code[10]) { }
|
||||
field(4; "Posting Date"; Date) { }
|
||||
field(5; Quantity; Decimal) { }
|
||||
}
|
||||
|
||||
keys
|
||||
{
|
||||
key(PK; "Entry No.") { Clustered = true; }
|
||||
key(ByItemLocationDate; "Item No.", "Location Code", "Posting Date")
|
||||
{
|
||||
SumIndexFields = Quantity;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
table 50368 "Perf Quantity Summary"
|
||||
{
|
||||
DataClassification = CustomerContent;
|
||||
|
||||
fields
|
||||
{
|
||||
field(1; "Run ID"; Guid) { }
|
||||
field(2; "Item No."; Code[20]) { }
|
||||
field(3; "Location Code"; Code[10]) { }
|
||||
field(4; Quantity; Decimal) { }
|
||||
}
|
||||
|
||||
keys
|
||||
{
|
||||
key(PK; "Run ID", "Item No.", "Location Code") { Clustered = true; }
|
||||
}
|
||||
}
|
||||
|
||||
codeunit 50369 "Perf Summary Bad"
|
||||
{
|
||||
procedure BuildSummary(CutoffDate: Date) RunId: Guid
|
||||
var
|
||||
Entry: Record "Perf Posted Entry";
|
||||
Scan: Record "Perf Posted Entry";
|
||||
Summary: Record "Perf Quantity Summary";
|
||||
begin
|
||||
RunId := CreateGuid();
|
||||
Entry.SetFilter("Posting Date", '..%1', CutoffDate);
|
||||
if Entry.FindSet() then
|
||||
repeat
|
||||
Scan.SetRange("Item No.", Entry."Item No.");
|
||||
Scan.SetRange("Location Code", Entry."Location Code");
|
||||
Scan.SetFilter("Posting Date", '..%1', CutoffDate);
|
||||
Scan.CalcSums(Quantity);
|
||||
|
||||
if Summary.Get(RunId, Entry."Item No.", Entry."Location Code") then begin
|
||||
Summary.Quantity := Scan.Quantity;
|
||||
Summary.Modify(false);
|
||||
end else begin
|
||||
Summary.Init();
|
||||
Summary."Run ID" := RunId;
|
||||
Summary."Item No." := Entry."Item No.";
|
||||
Summary."Location Code" := Entry."Location Code";
|
||||
Summary.Quantity := Scan.Quantity;
|
||||
Summary.Insert(false);
|
||||
end;
|
||||
until Entry.Next() = 0;
|
||||
end;
|
||||
}
|
||||
|
|
@ -0,0 +1,93 @@
|
|||
// Input stays stable for this run; output contains one row per group with entries.
|
||||
table 50367 "Perf Posted Entry"
|
||||
{
|
||||
DataClassification = CustomerContent;
|
||||
|
||||
fields
|
||||
{
|
||||
field(1; "Entry No."; Integer) { }
|
||||
field(2; "Item No."; Code[20]) { }
|
||||
field(3; "Location Code"; Code[10]) { }
|
||||
field(4; "Posting Date"; Date) { }
|
||||
field(5; Quantity; Decimal) { }
|
||||
}
|
||||
|
||||
keys
|
||||
{
|
||||
key(PK; "Entry No.") { Clustered = true; }
|
||||
key(ByItemLocationDate; "Item No.", "Location Code", "Posting Date")
|
||||
{
|
||||
SumIndexFields = Quantity;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
table 50368 "Perf Quantity Summary"
|
||||
{
|
||||
DataClassification = CustomerContent;
|
||||
|
||||
fields
|
||||
{
|
||||
field(1; "Run ID"; Guid) { }
|
||||
field(2; "Item No."; Code[20]) { }
|
||||
field(3; "Location Code"; Code[10]) { }
|
||||
field(4; Quantity; Decimal) { }
|
||||
}
|
||||
|
||||
keys
|
||||
{
|
||||
key(PK; "Run ID", "Item No.", "Location Code") { Clustered = true; }
|
||||
}
|
||||
}
|
||||
|
||||
query 50370 "Perf Grouped Quantity"
|
||||
{
|
||||
QueryType = Normal;
|
||||
|
||||
elements
|
||||
{
|
||||
dataitem(Entry; "Perf Posted Entry")
|
||||
{
|
||||
column(ItemNo; "Item No.") { }
|
||||
column(LocationCode; "Location Code") { }
|
||||
column(TotalQuantity; Quantity)
|
||||
{
|
||||
Method = Sum;
|
||||
}
|
||||
filter(PostingDate; "Posting Date") { }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
codeunit 50369 "Perf Summary Good"
|
||||
{
|
||||
procedure BuildSummary(CutoffDate: Date) RunId: Guid
|
||||
var
|
||||
Totals: Query "Perf Grouped Quantity";
|
||||
TempSummary: Record "Perf Quantity Summary" temporary;
|
||||
Summary: Record "Perf Quantity Summary";
|
||||
begin
|
||||
RunId := CreateGuid();
|
||||
Totals.SetFilter(PostingDate, '..%1', CutoffDate);
|
||||
Totals.Open();
|
||||
while Totals.Read() do begin
|
||||
TempSummary.Init();
|
||||
TempSummary."Run ID" := RunId;
|
||||
TempSummary."Item No." := Totals.ItemNo;
|
||||
TempSummary."Location Code" := Totals.LocationCode;
|
||||
TempSummary.Quantity := Totals.TotalQuantity;
|
||||
TempSummary.Insert();
|
||||
end;
|
||||
Totals.Close();
|
||||
|
||||
if TempSummary.FindSet() then
|
||||
repeat
|
||||
Summary.Init();
|
||||
Summary."Run ID" := RunId;
|
||||
Summary."Item No." := TempSummary."Item No.";
|
||||
Summary."Location Code" := TempSummary."Location Code";
|
||||
Summary.Quantity := TempSummary.Quantity;
|
||||
Summary.Insert(false);
|
||||
until TempSummary.Next() = 0;
|
||||
end;
|
||||
}
|
||||
|
|
@ -0,0 +1,28 @@
|
|||
---
|
||||
bc-version: [all]
|
||||
domain: performance
|
||||
keywords: [query, grouping, aggregate, intermediate-results, temporary-buffer, persistent-writes, calcsums, modify, method-sum]
|
||||
technologies: [al]
|
||||
countries: [w1]
|
||||
application-area: [all]
|
||||
---
|
||||
|
||||
# Aggregate source rows before persisting completed results
|
||||
|
||||
## Description
|
||||
|
||||
A batch may repeatedly calculate the same group total while inserting and updating persistent working rows, even though only one completed result per group is needed. An AL Query with `Method = Sum` groups by its other output columns, so the required grain can often be computed from qualifying source rows before writing final output. This is a data-path change, not a blanket replacement for posting or for intermediate rows needed for recovery or business behavior.
|
||||
|
||||
## Best Practice
|
||||
|
||||
Specify the output grain and whether absent groups should produce zero rows. Apply source filters (including cutoff dates), aggregate at exactly that grain, and write only completed results; a small temporary buffer can separate the query from persistent output, but streaming or bounded units may be better for large grouped sets. Avoid a one-to-many join that duplicates quantities or an extra output column that silently changes the grouping. Preserve security filters, signs, units, FlowFilters, relevant master-data restrictions, transaction/trigger behavior, and acceptable read consistency; a shared date cutoff does not create a snapshot. Compare complete keyed outputs and measure source reads, repeated calculations, temporary memory, and persistent writes. See sample: [`aggregate-before-persisting-intermediate-results.good.al`](aggregate-before-persisting-intermediate-results.good.al).
|
||||
|
||||
## Anti Pattern
|
||||
|
||||
For an output that only needs one signed total per item/location with qualifying entries, summing the same source range and rewriting a persistent summary for *every* entry. Do not flag incremental persisted state that is required for locking, resumability, or downstream processing, or require an in-memory copy of all raw history to perform SQL grouping. See sample: [`aggregate-before-persisting-intermediate-results.bad.al`](aggregate-before-persisting-intermediate-results.bad.al).
|
||||
|
||||
## References
|
||||
|
||||
- [Aggregating data in query objects](https://learn.microsoft.com/en-us/dynamics365/business-central/dev-itpro/developer/devenv-query-totals-grouping).
|
||||
- [Query performance](https://learn.microsoft.com/en-us/dynamics365/business-central/dev-itpro/administration/optimize-sql-query-objects-and-performance).
|
||||
- [Buffered inserts](preserve-buffered-inserts-by-separating-target-reads.md).
|
||||
|
|
@ -1,7 +1,7 @@
|
|||
---
|
||||
bc-version: [all]
|
||||
domain: performance
|
||||
keywords: [n-plus-one, get, findfirst, loop, inner-lookup, large-table]
|
||||
keywords: [n-plus-one, get, findfirst, loop, inner-lookup, large-table, item-get, query]
|
||||
technologies: [al]
|
||||
countries: [w1]
|
||||
application-area: [all]
|
||||
|
|
@ -15,7 +15,7 @@ A `Get` or `FindFirst` against another persistent table inside a loop can produc
|
|||
|
||||
## Best Practice
|
||||
|
||||
Use a query object to join the outer and inner tables when the relationship and filters can be expressed as one query. If keys repeat, a dictionary cache can reduce lookups to one per distinct key. `SetLoadFields` can reduce the columns transferred by unavoidable inner reads, but it does not eliminate the N+1 shape and must not be presented as doing so.
|
||||
Use a query object to join the outer and inner tables when the relationship and filters can be expressed as one query. If complete lookup keys repeat and the result remains valid, a [scoped cache](cache-repeated-filtered-results-with-explicit-scope.md) can reduce lookups to one per distinct key; do not assume primary-key `Get` calls each hit SQL (see [transaction caching](primary-key-get-in-loop-is-transaction-cached.md)). `SetLoadFields` can reduce the columns transferred by unavoidable inner reads, but it does not eliminate the N+1 shape and must not be presented as doing so.
|
||||
|
||||
See sample: [`avoid-get-inside-loop-on-large-table.good.al`](avoid-get-inside-loop-on-large-table.good.al).
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,39 @@
|
|||
// Quantity validation depends only on Quantity and Unit Price in this demo.
|
||||
table 50371 "Perf Validated Line"
|
||||
{
|
||||
DataClassification = CustomerContent;
|
||||
|
||||
fields
|
||||
{
|
||||
field(1; "Line No."; Integer) { }
|
||||
field(2; Quantity; Decimal)
|
||||
{
|
||||
trigger OnValidate()
|
||||
begin
|
||||
Amount := Quantity * "Unit Price";
|
||||
end;
|
||||
}
|
||||
field(3; "Unit Price"; Decimal) { }
|
||||
field(4; Amount; Decimal) { }
|
||||
field(5; Note; Text[100]) { }
|
||||
}
|
||||
|
||||
keys
|
||||
{
|
||||
key(PK; "Line No.") { Clustered = true; }
|
||||
}
|
||||
}
|
||||
|
||||
codeunit 50372 "Perf Validation Bad"
|
||||
{
|
||||
procedure UpdateLine(LineNo: Integer; NewQuantity: Decimal; NewNote: Text[100])
|
||||
var
|
||||
Line: Record "Perf Validated Line";
|
||||
begin
|
||||
Line.Get(LineNo);
|
||||
Line.Validate(Quantity, NewQuantity);
|
||||
Line.Note := NewNote;
|
||||
Line.Validate(Quantity, NewQuantity);
|
||||
Line.Modify(false);
|
||||
end;
|
||||
}
|
||||
|
|
@ -0,0 +1,38 @@
|
|||
// Quantity validation depends only on Quantity and Unit Price in this demo.
|
||||
table 50371 "Perf Validated Line"
|
||||
{
|
||||
DataClassification = CustomerContent;
|
||||
|
||||
fields
|
||||
{
|
||||
field(1; "Line No."; Integer) { }
|
||||
field(2; Quantity; Decimal)
|
||||
{
|
||||
trigger OnValidate()
|
||||
begin
|
||||
Amount := Quantity * "Unit Price";
|
||||
end;
|
||||
}
|
||||
field(3; "Unit Price"; Decimal) { }
|
||||
field(4; Amount; Decimal) { }
|
||||
field(5; Note; Text[100]) { }
|
||||
}
|
||||
|
||||
keys
|
||||
{
|
||||
key(PK; "Line No.") { Clustered = true; }
|
||||
}
|
||||
}
|
||||
|
||||
codeunit 50372 "Perf Validation Good"
|
||||
{
|
||||
procedure UpdateLine(LineNo: Integer; NewQuantity: Decimal; NewNote: Text[100])
|
||||
var
|
||||
Line: Record "Perf Validated Line";
|
||||
begin
|
||||
Line.Get(LineNo);
|
||||
Line.Validate(Quantity, NewQuantity);
|
||||
Line.Note := NewNote;
|
||||
Line.Modify(false);
|
||||
end;
|
||||
}
|
||||
|
|
@ -0,0 +1,28 @@
|
|||
---
|
||||
bc-version: [all]
|
||||
domain: performance
|
||||
keywords: [validate, onvalidate, repeated-write, sales-line, subscriber, unchanged-value]
|
||||
technologies: [al]
|
||||
countries: [w1]
|
||||
application-area: [all]
|
||||
---
|
||||
|
||||
# Avoid repeating validation when its inputs have not changed
|
||||
|
||||
## Description
|
||||
|
||||
`Validate` runs field validation logic, which can invoke subscribers and additional reads or writes even if the assigned value is unchanged. When a document-building path validates the same field twice without changing any input its validation depends on, the second cascade may do the same work again. A field-value comparison alone does not prove that the dependent context or required event behavior is unchanged.
|
||||
|
||||
## Best Practice
|
||||
|
||||
Trace the table's `OnValidate`, subscribers, and dependent fields before removing a duplicate invocation. Keep the required validation and write, but skip a second `Validate` only when all its inputs, its order-dependent effects, and the business contract are demonstrably unchanged. Measure validations and writes per business unit and test pricing, reservations, error behavior, and subscriber effects on real document paths. The sample's custom field validation depends only on Quantity and Unit Price; a note assigned between calls does not affect either. See sample: [`avoid-repeating-unchanged-validation.good.al`](avoid-repeating-unchanged-validation.good.al).
|
||||
|
||||
## Anti Pattern
|
||||
|
||||
Validating Quantity, changing only an unrelated local or record note, validating the same Quantity again, then modifying the row, without any required second event. Do not replace `Validate` with direct assignment, use `Insert(false)` indiscriminately, or reorder validations on a Sales Line solely to reduce call counts: its standard logic can depend on other fields and event subscribers. See sample: [`avoid-repeating-unchanged-validation.bad.al`](avoid-repeating-unchanged-validation.bad.al).
|
||||
|
||||
## References
|
||||
|
||||
- [Record.Validate and field validation behavior](https://learn.microsoft.com/en-us/dynamics365/business-central/dev-itpro/developer/methods-auto/record/record-validate-method).
|
||||
- [Equivalent bulk assignments versus per-row validation](prefer-modifyall-over-per-row-modify.md).
|
||||
- [Subscriber guards](guard-event-subscribers-before-db-call.md).
|
||||
|
|
@ -0,0 +1,24 @@
|
|||
codeunit 50363 "Perf Variant Cache Bad"
|
||||
{
|
||||
procedure CountAndCollectVariantLines(var TempSalesLine: Record "Sales Line" temporary; OrderNo: Code[20]; var VariantItemNos: List of [Code[20]]) VariantLines: Integer
|
||||
var
|
||||
ItemVariant: Record "Item Variant";
|
||||
begin
|
||||
TempSalesLine.SetRange("Document Type", TempSalesLine."Document Type"::Order);
|
||||
TempSalesLine.SetRange("Document No.", OrderNo);
|
||||
TempSalesLine.SetRange(Type, TempSalesLine.Type::Item);
|
||||
if TempSalesLine.FindSet() then
|
||||
repeat
|
||||
ItemVariant.SetRange("Item No.", TempSalesLine."No.");
|
||||
if not ItemVariant.IsEmpty() then
|
||||
VariantLines += 1;
|
||||
until TempSalesLine.Next() = 0;
|
||||
|
||||
if TempSalesLine.FindSet() then
|
||||
repeat
|
||||
ItemVariant.SetRange("Item No.", TempSalesLine."No.");
|
||||
if not ItemVariant.IsEmpty() then
|
||||
VariantItemNos.Add(TempSalesLine."No.");
|
||||
until TempSalesLine.Next() = 0;
|
||||
end;
|
||||
}
|
||||
|
|
@ -0,0 +1,48 @@
|
|||
codeunit 50363 "Perf Variant Cache Good"
|
||||
{
|
||||
procedure CountAndCollectVariantLines(var TempSalesLine: Record "Sales Line" temporary; OrderNo: Code[20]; var VariantItemNos: List of [Code[20]]) VariantLines: Integer
|
||||
var
|
||||
ItemVariant: Record "Item Variant";
|
||||
HasVariantsByItem: Dictionary of [Code[20], Boolean];
|
||||
begin
|
||||
TempSalesLine.SetRange("Document Type", TempSalesLine."Document Type"::Order);
|
||||
TempSalesLine.SetRange("Document No.", OrderNo);
|
||||
TempSalesLine.SetRange(Type, TempSalesLine.Type::Item);
|
||||
if TempSalesLine.FindSet() then
|
||||
repeat
|
||||
if HasVariants(TempSalesLine."No.", ItemVariant, HasVariantsByItem) then
|
||||
VariantLines += 1;
|
||||
until TempSalesLine.Next() = 0;
|
||||
|
||||
if TempSalesLine.FindSet() then
|
||||
repeat
|
||||
if HasVariants(TempSalesLine."No.", ItemVariant, HasVariantsByItem) then
|
||||
VariantItemNos.Add(TempSalesLine."No.");
|
||||
until TempSalesLine.Next() = 0;
|
||||
end;
|
||||
|
||||
local procedure HasVariants(ItemNo: Code[20]; var ItemVariant: Record "Item Variant"; var HasVariantsByItem: Dictionary of [Code[20], Boolean]): Boolean
|
||||
var
|
||||
CachedResult: Boolean;
|
||||
begin
|
||||
if HasVariantsByItem.Get(ItemNo, CachedResult) then
|
||||
exit(CachedResult);
|
||||
|
||||
ItemVariant.SetRange("Item No.", ItemNo);
|
||||
CachedResult := not ItemVariant.IsEmpty();
|
||||
HasVariantsByItem.Add(ItemNo, CachedResult);
|
||||
exit(CachedResult);
|
||||
end;
|
||||
|
||||
procedure CountDistinctItemsWithVariants(var TempItems: Record Item temporary) VariantItems: Integer
|
||||
var
|
||||
ItemVariant: Record "Item Variant";
|
||||
begin
|
||||
if TempItems.FindSet() then
|
||||
repeat
|
||||
ItemVariant.SetRange("Item No.", TempItems."No.");
|
||||
if not ItemVariant.IsEmpty() then
|
||||
VariantItems += 1;
|
||||
until TempItems.Next() = 0;
|
||||
end;
|
||||
}
|
||||
|
|
@ -0,0 +1,27 @@
|
|||
---
|
||||
bc-version: [all]
|
||||
domain: performance
|
||||
keywords: [cache, dictionary, filtered-lookup, isempty, repeated-query, invalidation, scope]
|
||||
technologies: [al]
|
||||
countries: [w1]
|
||||
application-area: [all]
|
||||
---
|
||||
|
||||
# Reuse repeated filtered results within a correct cache scope
|
||||
|
||||
## Description
|
||||
|
||||
A loop can ask the same filtered existence or calculation question for many rows sharing a business key, or two phases can ask it for the same rows. Unlike repeated primary-key `Get` calls, non-keyed filtered lookups are not automatically answered by the primary-key record cache. Memoization can remove repeated AL and data-access work, but a cache keyed by too few inputs or kept past a data change returns the wrong answer. A single pass over lines with unknown, possibly distinct item numbers does not establish reuse.
|
||||
|
||||
## Best Practice
|
||||
|
||||
First reuse an already-loaded result if valid. Require evidence that complete lookup keys actually repeat, such as repeated queries for the same item in two passes over an unchanged line set (as in the sample), or measured cache hits. For a repeated, stable filtered lookup, keep a local dictionary for one operation, keyed by every input that affects the result (including company, filters, date, unit, currency, and quantity where applicable). Cache negative results as well as positive ones; distinguish a missing dictionary entry from an entry whose value is `false`. If underlying records can change during the run, update or invalidate the entry, or do not cache it. Bound entries or process in chunks when key cardinality is large. Check distinct complete keys, hits/misses, SQL work, AL time, and memory before adding a cache to a low-reuse workload. Use a temporary table for record-shaped values or multiple keys. See sample: [`cache-repeated-filtered-results-with-explicit-scope.good.al`](cache-repeated-filtered-results-with-explicit-scope.good.al).
|
||||
|
||||
## Anti Pattern
|
||||
|
||||
Running the same filtered `IsEmpty` in two passes over the same unchanged lines (even when every item number is distinct within a pass), or caching a price by item alone when customer, variant, date, and quantity affect it. Do **not** infer a cache opportunity from a single pass with no established key reuse, such as visiting each distinct Item once; the [good sample](cache-repeated-filtered-results-with-explicit-scope.good.al) also shows this valid direct lookup. Do not equate each `Get` with a SQL round trip or automatically wrap a cached primary-key read in another dictionary; see [primary-key cache exceptions](primary-key-get-in-loop-is-transaction-cached.md). See sample: [`cache-repeated-filtered-results-with-explicit-scope.bad.al`](cache-repeated-filtered-results-with-explicit-scope.bad.al).
|
||||
|
||||
## References
|
||||
|
||||
- [Data access and caching](https://learn.microsoft.com/en-us/dynamics365/business-central/dev-itpro/administration/optimize-sql-data-access).
|
||||
- [Dictionary type and `Get`](https://learn.microsoft.com/en-us/dynamics365/business-central/dev-itpro/developer/methods-auto/dictionary/dictionary-data-type).
|
||||
|
|
@ -1,15 +1,14 @@
|
|||
codeunit 50223 "Perf Sample CalcSums Bad"
|
||||
{
|
||||
procedure TotalRemaining(CustomerNo: Code[20]) Total: Decimal
|
||||
procedure TotalSales(CustomerNo: Code[20]) Total: Decimal
|
||||
var
|
||||
CustLedgerEntry: Record "Cust. Ledger Entry";
|
||||
begin
|
||||
CustLedgerEntry.SetCurrentKey("Customer No.");
|
||||
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";
|
||||
Total += CustLedgerEntry."Sales (LCY)";
|
||||
until CustLedgerEntry.Next() = 0;
|
||||
end;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,11 +1,12 @@
|
|||
codeunit 50222 "Perf Sample CalcSums Good"
|
||||
{
|
||||
procedure TotalRemaining(CustomerNo: Code[20]) Total: Decimal
|
||||
procedure TotalSales(CustomerNo: Code[20]) Total: Decimal
|
||||
var
|
||||
CustLedgerEntry: Record "Cust. Ledger Entry";
|
||||
begin
|
||||
CustLedgerEntry.SetCurrentKey("Customer No.");
|
||||
CustLedgerEntry.SetRange("Customer No.", CustomerNo);
|
||||
CustLedgerEntry.CalcSums("Remaining Amount");
|
||||
Total := CustLedgerEntry."Remaining Amount";
|
||||
CustLedgerEntry.CalcSums("Sales (LCY)");
|
||||
Total := CustLedgerEntry."Sales (LCY)";
|
||||
end;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,26 +1,31 @@
|
|||
---
|
||||
bc-version: [all]
|
||||
domain: performance
|
||||
keywords: [calcfields, calcsums, loop, flowfield, n-plus-one, aggregation]
|
||||
keywords: [calcfields, calcsums, loop, flowfield, source-field, sumindexfields, aggregation]
|
||||
technologies: [al]
|
||||
countries: [w1]
|
||||
application-area: [all]
|
||||
---
|
||||
|
||||
# Use CalcSums to aggregate, not CalcFields inside a loop
|
||||
# Use CalcSums for stored-field totals, not as a shortcut for FlowFields
|
||||
|
||||
## 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()`.
|
||||
`CalcFields` evaluates a FlowField for one record; `CalcSums` totals stored numeric fields in a filtered source table. They do not generally answer the same question. A loop that adds a normal source field for one total can often use one `CalcSums`, possibly backed by a compatible SIFT index. A loop that adds calculated FlowFields cannot be replaced with `CalcSums` on those FlowFields: each `CalcFormula` may depend on its parent record, FlowFilters, and the selected parent set. `CalcFields` requests can also use a recent calculation cache, so source-level call counts are not SQL statement counts.
|
||||
|
||||
## 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.
|
||||
When only one total over stored source fields is needed, set the source-table filters and call `CalcSums` on those fields; select an appropriate current key with `SumIndexFields` when relying on SIFT, and measure the read/write trade-off. If the inputs are FlowFields, derive any proposed source aggregation from their `CalcFormula`, including the selected parent set and FlowFilters, and verify equivalent results before replacing the loop. When every row needs its own FlowField value, [use `SetAutoCalcFields`](use-setautocalcfields-for-per-row-flowfields.md) where appropriate rather than replacing row values with one total. See [SIFT trade-offs](choose-maintainsiftindex-by-read-write-ratio.md).
|
||||
|
||||
See sample: [`calcsums-instead-of-calcfields-in-loop.good.al`](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.
|
||||
Looping over filtered `Cust. Ledger Entry` records and adding the stored `"Sales (LCY)"` field when the only output is its total. The opposite mistake is proposing `Customer.CalcSums(Balance)` as a generic replacement for adding selected customers' FlowField balances; that changes or fails to express the required calculation.
|
||||
|
||||
See sample: [`calcsums-instead-of-calcfields-in-loop.bad.al`](calcsums-instead-of-calcfields-in-loop.bad.al).
|
||||
|
||||
## References
|
||||
|
||||
- [CalcFields and CalcSums operate on different field calculations](https://learn.microsoft.com/en-us/dynamics365/business-central/dev-itpro/developer/devenv-calcfields-calcsums-fielderror-fieldname-init-testfield-and-validate-methods).
|
||||
- [Record.CalcSums](https://learn.microsoft.com/en-us/dynamics365/business-central/dev-itpro/developer/methods-auto/record/record-calcsums-method).
|
||||
|
|
|
|||
|
|
@ -0,0 +1,38 @@
|
|||
table 50360 "Perf Read Entry"
|
||||
{
|
||||
DataClassification = CustomerContent;
|
||||
|
||||
fields
|
||||
{
|
||||
field(1; "Entry No."; Integer) { }
|
||||
field(2; "Customer No."; Code[20]) { }
|
||||
field(3; "Posting Date"; Date) { }
|
||||
field(4; "Item No."; Code[20]) { }
|
||||
field(5; Quantity; Decimal) { }
|
||||
field(6; Description; Text[100]) { }
|
||||
}
|
||||
|
||||
keys
|
||||
{
|
||||
key(PK; "Entry No.") { Clustered = true; }
|
||||
key(ByDescription; Description, "Item No.", "Posting Date") { }
|
||||
key(ByQuantity; Quantity, "Customer No.") { }
|
||||
}
|
||||
}
|
||||
|
||||
codeunit 50361 "Perf Read Entry Bad"
|
||||
{
|
||||
procedure SumNonblankItems(CustomerNo: Code[20]; FromDate: Date; ToDate: Date) Total: Decimal
|
||||
var
|
||||
Entry: Record "Perf Read Entry";
|
||||
begin
|
||||
Entry.SetRange("Customer No.", CustomerNo);
|
||||
Entry.SetRange("Posting Date", FromDate, ToDate);
|
||||
Entry.SetLoadFields("Item No.", Quantity);
|
||||
if Entry.FindSet() then
|
||||
repeat
|
||||
if Entry."Item No." <> '' then
|
||||
Total += Entry.Quantity;
|
||||
until Entry.Next() = 0;
|
||||
end;
|
||||
}
|
||||
|
|
@ -0,0 +1,41 @@
|
|||
table 50360 "Perf Read Entry"
|
||||
{
|
||||
DataClassification = CustomerContent;
|
||||
|
||||
fields
|
||||
{
|
||||
field(1; "Entry No."; Integer) { }
|
||||
field(2; "Customer No."; Code[20]) { }
|
||||
field(3; "Posting Date"; Date) { }
|
||||
field(4; "Item No."; Code[20]) { }
|
||||
field(5; Quantity; Decimal) { }
|
||||
field(6; Description; Text[100]) { }
|
||||
}
|
||||
|
||||
keys
|
||||
{
|
||||
key(PK; "Entry No.") { Clustered = true; }
|
||||
key(ByCustomerDate; "Customer No.", "Posting Date")
|
||||
{
|
||||
// Supports the filters and explicit payload; implicit system fields may still require lookups.
|
||||
IncludedFields = "Item No.", Quantity;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
codeunit 50361 "Perf Read Entry Good"
|
||||
{
|
||||
procedure SumNonblankItems(CustomerNo: Code[20]; FromDate: Date; ToDate: Date) Total: Decimal
|
||||
var
|
||||
Entry: Record "Perf Read Entry";
|
||||
begin
|
||||
Entry.SetRange("Customer No.", CustomerNo);
|
||||
Entry.SetRange("Posting Date", FromDate, ToDate);
|
||||
Entry.SetLoadFields("Item No.", Quantity);
|
||||
if Entry.FindSet() then
|
||||
repeat
|
||||
if Entry."Item No." <> '' then
|
||||
Total += Entry.Quantity;
|
||||
until Entry.Next() = 0;
|
||||
end;
|
||||
}
|
||||
|
|
@ -0,0 +1,30 @@
|
|||
---
|
||||
bc-version: [19..]
|
||||
domain: performance
|
||||
keywords: [includedfields, covering-index, secondary-key, filter, projection, selectivity, key, setrange]
|
||||
technologies: [al]
|
||||
countries: [w1]
|
||||
application-area: [all]
|
||||
---
|
||||
|
||||
# Design a read-pattern key and verify whether it covers the query
|
||||
|
||||
## Description
|
||||
|
||||
A secondary key should serve a particular read, not a list of fields that happen to look important. The order of key fields affects which filters and sort orders it can support; `IncludedFields` supplies non-key payload columns without making them ordered key columns. `SetCurrentKey` sets an order, not an index hint (see [sort guidance](setcurrentkey-sets-sort-order-not-index-hint.md)). A key that supports the predicates is not necessarily a covering index, and coverage alone does not make a query selective.
|
||||
|
||||
## Best Practice
|
||||
|
||||
For a costly, frequent read, establish its equality and range filters, joins, required ordering, actual SQL projection, cardinality, and existing physical indexes. Test a key whose leading fields support the useful predicates; for example, customer equality followed by a posting-date range. On a nonclustered secondary key, consider `IncludedFields` for small payload fields read but not filtered or ordered. The sample's key supports customer/date filters and includes the explicit payload, but is **not demonstrated to cover the read**: Business Central also projects `SystemId` and system audit fields on partial records. Check the generated SQL, including automatically selected, clustered-key, and extension fields, against the physical index before claiming coverage. Adding more included fields has storage and write-maintenance costs; measure reads, sorts, lookups, latency, and writes before expanding it. The [Database Missing Indexes page](https://learn.microsoft.com/en-us/dynamics365/business-central/dev-itpro/administration/database-missing-indexes) supplies candidates, not a mandate to add every suggested key.
|
||||
|
||||
`IncludedFields` requires runtime 8.0 (BC 19) or later and cannot be set on a primary or clustered secondary key. An included field does not participate in `SetCurrentKey` matching or maintain a SIFT sum. If the item is also a selective predicate or required ordering column, evaluate it as a key field instead. Respect table-extension key field-ownership restrictions. See sample: [`design-covering-keys-from-read-pattern.good.al`](design-covering-keys-from-read-pattern.good.al).
|
||||
|
||||
## Anti Pattern
|
||||
|
||||
Adding a key on output-only fields or requesting an unrelated `SetCurrentKey` ordering to "force" the optimizer to use that key, without establishing the read's filters or validating its plan. Likewise, calling an index covering just because it contains the fields explicitly listed in `SetLoadFields` ignores automatically projected columns. Do not report every uncovered read as a defect: a small table, a low-frequency query, or a write-heavy table may be better without another maintained index. See sample: [`design-covering-keys-from-read-pattern.bad.al`](design-covering-keys-from-read-pattern.bad.al).
|
||||
|
||||
## References
|
||||
|
||||
- [Table keys, included columns, and extension restrictions](https://learn.microsoft.com/en-us/dynamics365/business-central/dev-itpro/developer/devenv-table-keys).
|
||||
- [IncludedFields property](https://learn.microsoft.com/en-us/dynamics365/business-central/dev-itpro/developer/properties/devenv-includedfields-property).
|
||||
- [Table keys and performance](https://learn.microsoft.com/en-us/dynamics365/business-central/dev-itpro/administration/optimize-sql-table-keys-and-performance).
|
||||
|
|
@ -13,11 +13,11 @@ application-area: [all]
|
|||
|
||||
## Description
|
||||
|
||||
`IsEmpty` is the right API when the caller only needs existence — see `microsoft/knowledge/performance/use-isempty-for-existence-check.md`. It is not a cheap guard in front of a loop that will `FindSet` anyway. Both calls hit the database; `FindSet` already returns false when the filter matches nothing. Agents and reviewers often insert `if not Rec.IsEmpty() then` "for performance" and pay a second query for a result the iterator already provides.
|
||||
`IsEmpty` is the right API when the caller only needs existence — see `microsoft/knowledge/performance/use-isempty-for-existence-check.md`. It is not a cheap guard in front of a loop that will `FindSet` anyway. `FindSet` already returns false when the filter matches nothing; an extra `IsEmpty` is unnecessary AL work and can issue a second database request, depending on caching. Agents and reviewers often insert `if not Rec.IsEmpty() then` "for performance" and duplicate a result the iterator already provides.
|
||||
|
||||
## Best Practice
|
||||
|
||||
When the body iterates, open with `if Rec.FindSet() then repeat ... until Next() = 0`. Do not flag a bare `FindSet` loop as missing an `IsEmpty` precondition. Reserve `IsEmpty` for branches that never materialize the row set.
|
||||
When the body iterates, open with `if Rec.FindSet() then repeat ... until Next() = 0`. Do not flag a bare `FindSet` loop as missing an `IsEmpty` precondition. Reserve `IsEmpty` for branches that never materialize the row set. An early check before a *bulk write* or lock is a different, workload-dependent decision: it can help when the filtered set is usually empty, but adds work when rows exist and does not lock the set against change.
|
||||
|
||||
See sample: [`isempty-before-findset-is-extra-round-trip.good.al`](isempty-before-findset-is-extra-round-trip.good.al).
|
||||
|
||||
|
|
|
|||
|
|
@ -20,6 +20,7 @@ codeunit 50242 "Perf Sample ModifyAll Good"
|
|||
StagingEntry: Record "Perf Import Staging Entry";
|
||||
begin
|
||||
StagingEntry.SetRange("Batch ID", BatchId);
|
||||
StagingEntry.SetRange(Processed, false);
|
||||
// Processed has no OnValidate logic, and the equivalent loop uses Modify(false).
|
||||
StagingEntry.ModifyAll(Processed, true, false);
|
||||
end;
|
||||
|
|
|
|||
|
|
@ -15,7 +15,7 @@ application-area: [all]
|
|||
|
||||
## Best Practice
|
||||
|
||||
Use `ModifyAll` when the loop directly assigns the same value, does not call `Validate`, needs no per-row calculation, and does not depend on `OnModify` unless the equivalent `RunTrigger` value is supplied. Check whether table trigger code, related subscribers, security filtering, `Media`/`MediaSet`, or companion fields force row-by-row fallback (see `triggers-and-media-field-regress-modifyall.md`). A visible loop for progress UX is acceptable only when evidence shows the equivalent bulk call already executes as individual operations and the loop preserves trigger and business semantics.
|
||||
Use `ModifyAll` when the loop directly assigns the same value, does not call `Validate`, needs no per-row calculation, and does not depend on `OnModify` unless the equivalent `RunTrigger` value is supplied. For a data-only update, exclude rows already holding the target value when that filter preserves the business outcome. Do not skip unchanged rows if the original call's per-row effects are required. Check whether table trigger code, related subscribers, security filtering, `Media`/`MediaSet`, or companion fields force row-by-row fallback (see `triggers-and-media-field-regress-modifyall.md`). A visible loop for progress UX is acceptable only when evidence shows the equivalent bulk call already executes as individual operations and the loop preserves trigger and business semantics.
|
||||
|
||||
See sample: [`prefer-modifyall-over-per-row-modify.good.al`](prefer-modifyall-over-per-row-modify.good.al).
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,61 @@
|
|||
// Each invocation exclusively owns a new run; these tables have no write logic.
|
||||
table 50364 "Perf Input"
|
||||
{
|
||||
DataClassification = CustomerContent;
|
||||
|
||||
fields
|
||||
{
|
||||
field(1; "Row No."; Integer) { }
|
||||
field(2; "Item No."; Code[20]) { }
|
||||
field(3; Quantity; Decimal) { }
|
||||
}
|
||||
|
||||
keys
|
||||
{
|
||||
key(PK; "Row No.") { Clustered = true; }
|
||||
}
|
||||
}
|
||||
|
||||
table 50365 "Perf Output"
|
||||
{
|
||||
DataClassification = CustomerContent;
|
||||
|
||||
fields
|
||||
{
|
||||
field(1; "Run ID"; Guid) { }
|
||||
field(2; "Line No."; Integer) { }
|
||||
field(3; "Item No."; Code[20]) { }
|
||||
field(4; Quantity; Decimal) { }
|
||||
}
|
||||
|
||||
keys
|
||||
{
|
||||
key(PK; "Run ID", "Line No.") { Clustered = true; }
|
||||
}
|
||||
}
|
||||
|
||||
codeunit 50366 "Perf Buffered Insert Bad"
|
||||
{
|
||||
procedure CopyRun(var TempInput: Record "Perf Input" temporary) RunId: Guid
|
||||
var
|
||||
Output: Record "Perf Output";
|
||||
NextLineNo: Integer;
|
||||
begin
|
||||
RunId := CreateGuid();
|
||||
Output.SetRange("Run ID", RunId);
|
||||
if TempInput.FindSet() then
|
||||
repeat
|
||||
if Output.FindLast() then
|
||||
NextLineNo := Output."Line No." + 1
|
||||
else
|
||||
NextLineNo := 1;
|
||||
Output.Init();
|
||||
Output."Run ID" := RunId;
|
||||
Output."Line No." := NextLineNo;
|
||||
Output."Item No." := TempInput."Item No.";
|
||||
Output.Insert(false);
|
||||
Output.Quantity := TempInput.Quantity;
|
||||
Output.Modify(false);
|
||||
until TempInput.Next() = 0;
|
||||
end;
|
||||
}
|
||||
|
|
@ -0,0 +1,56 @@
|
|||
// Each invocation exclusively owns a new run; these tables have no write logic.
|
||||
table 50364 "Perf Input"
|
||||
{
|
||||
DataClassification = CustomerContent;
|
||||
|
||||
fields
|
||||
{
|
||||
field(1; "Row No."; Integer) { }
|
||||
field(2; "Item No."; Code[20]) { }
|
||||
field(3; Quantity; Decimal) { }
|
||||
}
|
||||
|
||||
keys
|
||||
{
|
||||
key(PK; "Row No.") { Clustered = true; }
|
||||
}
|
||||
}
|
||||
|
||||
table 50365 "Perf Output"
|
||||
{
|
||||
DataClassification = CustomerContent;
|
||||
|
||||
fields
|
||||
{
|
||||
field(1; "Run ID"; Guid) { }
|
||||
field(2; "Line No."; Integer) { }
|
||||
field(3; "Item No."; Code[20]) { }
|
||||
field(4; Quantity; Decimal) { }
|
||||
}
|
||||
|
||||
keys
|
||||
{
|
||||
key(PK; "Run ID", "Line No.") { Clustered = true; }
|
||||
}
|
||||
}
|
||||
|
||||
codeunit 50366 "Perf Buffered Insert Good"
|
||||
{
|
||||
procedure CopyRun(var TempInput: Record "Perf Input" temporary) RunId: Guid
|
||||
var
|
||||
Output: Record "Perf Output";
|
||||
NextLineNo: Integer;
|
||||
begin
|
||||
RunId := CreateGuid();
|
||||
if TempInput.FindSet() then
|
||||
repeat
|
||||
NextLineNo += 1;
|
||||
Output.Init();
|
||||
Output."Run ID" := RunId;
|
||||
Output."Line No." := NextLineNo;
|
||||
Output."Item No." := TempInput."Item No.";
|
||||
Output.Quantity := TempInput.Quantity;
|
||||
Output.Insert(false);
|
||||
until TempInput.Next() = 0;
|
||||
end;
|
||||
}
|
||||
|
|
@ -0,0 +1,27 @@
|
|||
---
|
||||
bc-version: [all]
|
||||
domain: performance
|
||||
keywords: [buffered-inserts, bulk-inserts, findlast, insert, target-table, commit, staging]
|
||||
technologies: [al]
|
||||
countries: [w1]
|
||||
application-area: [all]
|
||||
---
|
||||
|
||||
# Keep eligible inserts together instead of re-reading their target
|
||||
|
||||
## Description
|
||||
|
||||
Business Central can automatically buffer eligible `Insert` calls. `Find`/`Calc` on the **target** table, `Modify`/`Delete` on it, or `Commit` flushes pending inserts; consuming the `Insert` return value, or BLOB/AutoIncrement fields on the target, prevents buffering. A source-table read is not itself a target-table flush. There is no general `InsertAll` replacement for an AL loop.
|
||||
|
||||
## Best Practice
|
||||
|
||||
When writing completed rows to an application-owned data-only table, allocate a collision-free run or range once, prepare every field before each `Insert`, and keep the insert sequence free of intervening target-table reads and writes. Let the owning business transaction determine the commit point. Trace called procedures and events as well as the visible loop; inspect actual SQL batches and writes, then test failures, retries, and any concurrent writers. The sample assumes an exclusive new run ID and no required trigger, validation, number-series, or subscriber effects. See sample: [`preserve-buffered-inserts-by-separating-target-reads.good.al`](preserve-buffered-inserts-by-separating-target-reads.good.al).
|
||||
|
||||
## Anti Pattern
|
||||
|
||||
Calling `FindLast` on the target for every row to allocate its next line number, inserting an incomplete row and immediately modifying it, or committing each iteration of an otherwise eligible insert sequence. Moving a shared `FindLast` out of the loop without concurrency-safe allocation is **not** a valid fix. Do not recommend skipping required triggers or persistence steps in sales documents, journals, or posting ledgers just to obtain buffering; repeated `Modify` calls do not automatically batch like eligible inserts. See sample: [`preserve-buffered-inserts-by-separating-target-reads.bad.al`](preserve-buffered-inserts-by-separating-target-reads.bad.al).
|
||||
|
||||
## References
|
||||
|
||||
- [Bulk inserts and flush/non-buffering conditions](https://learn.microsoft.com/en-us/dynamics365/business-central/dev-itpro/administration/optimize-sql-bulk-inserts).
|
||||
- [Transaction checkpoints and restart safety](avoid-commit-inside-loops.md).
|
||||
|
|
@ -13,12 +13,12 @@ application-area: [all]
|
|||
|
||||
The Business Central server caches primary-key reads within a transaction. Repeated `Record.Get(<primary key>)` calls for the same key are served from that cache rather than re-queried, so a guarded `if not Rec.Get(...) then exit;` inside a per-row helper is not a genuine N+1 pattern. When each row legitimately carries a distinct key — for example one `Bin Content` row per bin, so `Bin.Get` and `BinType.Get` see a different bin each iteration — the `Get` must run per row regardless, and there is nothing to hoist.
|
||||
|
||||
Reviewers sometimes see two `Get` calls inside a routine that runs once per row and recommend wrapping them in a `Dictionary` cache. That is over-engineering: it duplicates the server's built-in record cache, adds state that must be invalidated, and breaks the surrounding extension's established pattern of direct guarded `Get` calls.
|
||||
Reviewers sometimes see two `Get` calls inside a routine that runs once per row and recommend wrapping them in a `Dictionary` cache. Without evidence of a material additional cost, that duplicates the server's built-in record cache and adds state that must be invalidated. Filtered, non-keyed reads are a different case (see [repeated filtered results](cache-repeated-filtered-results-with-explicit-scope.md)).
|
||||
|
||||
## Best Practice
|
||||
|
||||
Treat a primary-key `Get()` — especially a guarded `if not Rec.Get(...) then exit;` — as a cheap, transaction-cached read. Do not recommend a manual `Dictionary` cache around per-row primary-key `Get` calls. Reserve N+1 concerns for genuinely repeated non-keyed queries (`FindSet`/`FindFirst` with filters, `Count`) that re-hit the database each iteration.
|
||||
Treat a primary-key `Get()` — especially a guarded `if not Rec.Get(...) then exit;` — as a transaction-cached read, not as proof of N+1 SQL. Do not recommend a manual cache solely from source-level call counts. If profiling shows repeated AL work or cache misses are material and the complete result can be reused safely, assess an explicitly scoped cache on its own merits. Investigate genuinely repeated non-keyed queries (`FindSet`/`FindFirst` with filters, `Count`) separately.
|
||||
|
||||
## Anti Pattern
|
||||
|
||||
Reporting repeated primary-key `Get` calls (such as `Bin.Get` and `BinType.Get`) inside a per-row helper as a performance defect, or recommending they be cached in a `Dictionary`. The reads are already cached by the server within the transaction, and per-row keys often differ so the calls cannot be hoisted.
|
||||
Reporting repeated primary-key `Get` calls (such as `Bin.Get` and `BinType.Get`) inside a per-row helper as one SQL round trip per call, or recommending a `Dictionary` without measuring reuse and cost. Per-row keys often differ, and the server can satisfy repeated keys from its transaction cache.
|
||||
|
|
|
|||
|
|
@ -13,7 +13,7 @@ application-area: [all]
|
|||
|
||||
## Description
|
||||
|
||||
The Business Central server caches primary-key `Get` calls within a transaction. Query objects do not use that cache: every `Open`/`Read` goes to SQL. `avoid-get-inside-loop-on-large-table.md` is right when an unbounded inner `Get`/`FindFirst` joins two large sets. It is wrong as a blanket rewrite of repeated `Get` on the same keys. Replacing a cached `Get` with a Query that re-executes per call can be slower. This file exists so reviewers stop treating every `Get` inside a loop as a Query candidate.
|
||||
The Business Central server caches primary-key `Get` calls within a transaction. Query objects do not use that primary-key cache: reopening a Query per lookup executes the query again; `Read` consumes rows from an open query, not a new query for each row. `avoid-get-inside-loop-on-large-table.md` is right when an unbounded inner `Get`/`FindFirst` joins two large sets. It is wrong as a blanket rewrite of repeated `Get` on the same keys. Replacing a cached `Get` with a Query reopened per call can be slower.
|
||||
|
||||
## Best Practice
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,43 @@
|
|||
// The only supported read filters customer/date and displays item, quantity, and amount.
|
||||
// No consumer needs ordering on the displayed fields or a SIFT aggregate.
|
||||
table 50362 "Perf Document Entry"
|
||||
{
|
||||
DataClassification = CustomerContent;
|
||||
|
||||
fields
|
||||
{
|
||||
field(1; "Entry No."; Integer) { }
|
||||
field(2; "Customer No."; Code[20]) { }
|
||||
field(3; "Posting Date"; Date) { }
|
||||
field(4; "Item No."; Code[20]) { }
|
||||
field(5; Quantity; Decimal) { }
|
||||
field(6; Amount; Decimal) { }
|
||||
}
|
||||
|
||||
keys
|
||||
{
|
||||
key(PK; "Entry No.") { Clustered = true; }
|
||||
key(CustomerDate; "Customer No.", "Posting Date") { }
|
||||
key(CustomerDateItem; "Customer No.", "Posting Date", "Item No.") { }
|
||||
key(CustomerDateQuantity; "Customer No.", "Posting Date", Quantity) { }
|
||||
key(CustomerDateAmount; "Customer No.", "Posting Date", Amount) { }
|
||||
}
|
||||
}
|
||||
|
||||
codeunit 50375 "Perf Document Reader Bad"
|
||||
{
|
||||
procedure CustomerDateTotals(CustomerNo: Code[20]; FromDate: Date; ToDate: Date; var ItemNos: List of [Code[20]]; var TotalAmount: Decimal; var TotalQuantity: Decimal)
|
||||
var
|
||||
Entry: Record "Perf Document Entry";
|
||||
begin
|
||||
Entry.SetRange("Customer No.", CustomerNo);
|
||||
Entry.SetRange("Posting Date", FromDate, ToDate);
|
||||
Entry.SetLoadFields("Item No.", Quantity, Amount);
|
||||
if Entry.FindSet() then
|
||||
repeat
|
||||
ItemNos.Add(Entry."Item No.");
|
||||
TotalQuantity += Entry.Quantity;
|
||||
TotalAmount += Entry.Amount;
|
||||
until Entry.Next() = 0;
|
||||
end;
|
||||
}
|
||||
|
|
@ -0,0 +1,43 @@
|
|||
// The only supported read filters customer/date and displays item, quantity, and amount.
|
||||
// No consumer needs ordering on the displayed fields or a SIFT aggregate.
|
||||
table 50362 "Perf Document Entry"
|
||||
{
|
||||
DataClassification = CustomerContent;
|
||||
|
||||
fields
|
||||
{
|
||||
field(1; "Entry No."; Integer) { }
|
||||
field(2; "Customer No."; Code[20]) { }
|
||||
field(3; "Posting Date"; Date) { }
|
||||
field(4; "Item No."; Code[20]) { }
|
||||
field(5; Quantity; Decimal) { }
|
||||
field(6; Amount; Decimal) { }
|
||||
}
|
||||
|
||||
keys
|
||||
{
|
||||
key(PK; "Entry No.") { Clustered = true; }
|
||||
key(CustomerDatePayload; "Customer No.", "Posting Date")
|
||||
{
|
||||
IncludedFields = "Item No.", Quantity, Amount;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
codeunit 50375 "Perf Document Reader Good"
|
||||
{
|
||||
procedure CustomerDateTotals(CustomerNo: Code[20]; FromDate: Date; ToDate: Date; var ItemNos: List of [Code[20]]; var TotalAmount: Decimal; var TotalQuantity: Decimal)
|
||||
var
|
||||
Entry: Record "Perf Document Entry";
|
||||
begin
|
||||
Entry.SetRange("Customer No.", CustomerNo);
|
||||
Entry.SetRange("Posting Date", FromDate, ToDate);
|
||||
Entry.SetLoadFields("Item No.", Quantity, Amount);
|
||||
if Entry.FindSet() then
|
||||
repeat
|
||||
ItemNos.Add(Entry."Item No.");
|
||||
TotalQuantity += Entry.Quantity;
|
||||
TotalAmount += Entry.Amount;
|
||||
until Entry.Next() = 0;
|
||||
end;
|
||||
}
|
||||
|
|
@ -0,0 +1,29 @@
|
|||
---
|
||||
bc-version: [all]
|
||||
domain: performance
|
||||
keywords: [overlapping-keys, redundant-index, includedfields, sift, write-amplification, index-portfolio, key, setrange]
|
||||
technologies: [al]
|
||||
countries: [w1]
|
||||
application-area: [all]
|
||||
---
|
||||
|
||||
# Review overlapping keys as a portfolio
|
||||
|
||||
## Description
|
||||
|
||||
Adding a secondary key to a frequently written table maintains another SQL index for every affected write. Several keys with the same leading fields might be serving distinct filters, sort orders, unique constraints, or SIFT aggregates; they might instead be redundant payload variants. A shared prefix, absent `SetCurrentKey` calls, or low usage in a short window is not proof that a key is unused.
|
||||
|
||||
## Best Practice
|
||||
|
||||
Before adding or removing a key, inventory keys from the table and installed extensions and identify each key's consumers and purpose: seek, ordering, uniqueness, or aggregation. Check `SQLIndex`, `MaintainSQLIndex`, `SumIndexFields`, and `MaintainSIFTIndex`, not only the AL key name. For *confirmed* payload-only variants on BC 19 or later, a single nonclustered key with `IncludedFields` can be a consolidation candidate (see [read-pattern key design](design-covering-keys-from-read-pattern.md)); an included field cannot replace a key column used for ordering or a maintained SIFT sum. Including the explicit payload does not prove that the resulting index covers every automatically selected field. Measure representative read and write workloads, including periodic reports, integrations, and other companies, before and after a supported extension/schema change. Retain a rollback path for a critical reader that regresses. See sample: [`review-overlapping-keys-before-adding-an-index.good.al`](review-overlapping-keys-before-adding-an-index.good.al).
|
||||
|
||||
## Anti Pattern
|
||||
|
||||
Keeping another customer/date key for each displayed field without checking whether the trailing fields support distinct operations. Conversely, merging `(Customer, Date)` with `(Customer, Item, Date)` merely because they share a prefix can regress item-selective queries; removing a unique key or a SIFT aggregate changes more than write cost. Do not report a key as unused solely because AL never calls `SetCurrentKey` on it. See sample: [`review-overlapping-keys-before-adding-an-index.bad.al`](review-overlapping-keys-before-adding-an-index.bad.al).
|
||||
|
||||
## References
|
||||
|
||||
- [Table keys: benefits, costs, and constraints](https://learn.microsoft.com/en-us/dynamics365/business-central/dev-itpro/developer/devenv-table-keys).
|
||||
- [IncludedFields property](https://learn.microsoft.com/en-us/dynamics365/business-central/dev-itpro/developer/properties/devenv-includedfields-property).
|
||||
- [Manage index usage (BC 2026 release wave 1 and later)](https://learn.microsoft.com/en-us/dynamics365/business-central/manage-indexes).
|
||||
- [SIFT read/write trade-off](choose-maintainsiftindex-by-read-write-ratio.md).
|
||||
|
|
@ -7,11 +7,11 @@ countries: [w1]
|
|||
application-area: [all]
|
||||
---
|
||||
|
||||
# SetCurrentKey only sets sort order — it is not an index hint
|
||||
# SetCurrentKey is not a SQL index hint for record reads
|
||||
|
||||
## Description
|
||||
|
||||
A common misconception is that `SetCurrentKey` tells SQL Server which index to use for a query. It does not. In Business Central, `SetCurrentKey` only changes the `ORDER BY` clause of the generated SQL statement. It does not add an index hint, and the SQL Server query optimizer is free to ignore the named key entirely.
|
||||
A common misconception is that `SetCurrentKey` tells SQL Server which index to use for a filtered `FindSet`/`FindFirst` query. It does not. For record iteration, `SetCurrentKey` changes the `ORDER BY` clause of the generated SQL statement; it does not add an index hint, and the SQL Server query optimizer is free to ignore the named key entirely. A distinct use is selecting an appropriate key with `SumIndexFields` before `CalcSums` so the platform can use a compatible SIFT aggregate (see [SIFT and SQL Server](https://learn.microsoft.com/en-us/dynamics365/business-central/dev-itpro/developer/devenv-sift-and-sql-server)).
|
||||
|
||||
The optimizer picks the index from the `WHERE` clause (your `SetRange`/`SetFilter`) together with table statistics and estimated cost. In practice it almost never chooses an index just because that key appears in `ORDER BY`. So calling `SetCurrentKey` to "steer" the plan toward an index is a no-op for index selection — and can make things worse: an `ORDER BY` that the query does not otherwise need can push the optimizer toward a less selective index or add a Sort operator to the plan.
|
||||
|
||||
|
|
@ -19,13 +19,15 @@ Selectivity comes from having the right index available (a key on the table whos
|
|||
|
||||
## Best Practice
|
||||
|
||||
Decide `SetCurrentKey` on one question only: **do I need the result set in a specific order?**
|
||||
For a record-iteration read, decide `SetCurrentKey` on one question: **do I need the result set in a specific order?**
|
||||
|
||||
- If yes — you iterate rows in a defined sequence, or rely on `FindFirst`/`FindLast`/`Next` returning a particular row — call `SetCurrentKey` for that sort. The order is a functional requirement, and the `ORDER BY` is justified.
|
||||
- If no — omit `SetCurrentKey`. Let the optimizer choose the cheapest plan for your filters; it may pick a better index and skip a sort.
|
||||
|
||||
To make a filtered read fast, ensure a key (index) exists on the table whose leading fields cover the filter, and filter on those fields with `SetRange`/`SetFilter`. That is what lets the optimizer seek. Defining the key creates the index; `SetCurrentKey` is not required to make the optimizer use it.
|
||||
|
||||
For a stored-field `CalcSums` instead of iteration, consider a matching current key with the summed field in `SumIndexFields`, as documented for SIFT; do not classify that call as an unnecessary `ORDER BY` on a row iterator (see [stored-field totals](calcsums-instead-of-calcfields-in-loop.md)).
|
||||
|
||||
See sample: [`setcurrentkey-sets-sort-order-not-index-hint.good.al`](setcurrentkey-sets-sort-order-not-index-hint.good.al).
|
||||
|
||||
## Anti Pattern
|
||||
|
|
|
|||
|
|
@ -0,0 +1,39 @@
|
|||
// Caller supplies an unfiltered temporary buffer that does not change during this call.
|
||||
table 50373 "Perf Cell"
|
||||
{
|
||||
DataClassification = CustomerContent;
|
||||
|
||||
fields
|
||||
{
|
||||
field(1; "Cell No."; Integer) { }
|
||||
field(2; "Item No."; Code[20]) { }
|
||||
field(3; Quantity; Decimal) { }
|
||||
}
|
||||
|
||||
keys
|
||||
{
|
||||
key(PK; "Cell No.") { Clustered = true; }
|
||||
}
|
||||
}
|
||||
|
||||
codeunit 50374 "Perf Temp Totals Bad"
|
||||
{
|
||||
procedure SumDisplayedItemTotals(var TempCells: Record "Perf Cell" temporary) DisplayedTotal: Decimal
|
||||
var
|
||||
TempScan: Record "Perf Cell" temporary;
|
||||
ItemTotal: Decimal;
|
||||
begin
|
||||
TempScan.Copy(TempCells, true);
|
||||
if TempCells.FindSet() then
|
||||
repeat
|
||||
TempScan.Reset();
|
||||
TempScan.SetRange("Item No.", TempCells."Item No.");
|
||||
ItemTotal := 0;
|
||||
if TempScan.FindSet() then
|
||||
repeat
|
||||
ItemTotal += TempScan.Quantity;
|
||||
until TempScan.Next() = 0;
|
||||
DisplayedTotal += ItemTotal;
|
||||
until TempCells.Next() = 0;
|
||||
end;
|
||||
}
|
||||
|
|
@ -0,0 +1,39 @@
|
|||
// Caller supplies an unfiltered temporary buffer that does not change during this call.
|
||||
table 50373 "Perf Cell"
|
||||
{
|
||||
DataClassification = CustomerContent;
|
||||
|
||||
fields
|
||||
{
|
||||
field(1; "Cell No."; Integer) { }
|
||||
field(2; "Item No."; Code[20]) { }
|
||||
field(3; Quantity; Decimal) { }
|
||||
}
|
||||
|
||||
keys
|
||||
{
|
||||
key(PK; "Cell No.") { Clustered = true; }
|
||||
}
|
||||
}
|
||||
|
||||
codeunit 50374 "Perf Temp Totals Good"
|
||||
{
|
||||
procedure SumDisplayedItemTotals(var TempCells: Record "Perf Cell" temporary) DisplayedTotal: Decimal
|
||||
var
|
||||
TotalsByItem: Dictionary of [Code[20], Decimal];
|
||||
ItemTotal: Decimal;
|
||||
begin
|
||||
if TempCells.FindSet() then
|
||||
repeat
|
||||
if TotalsByItem.Get(TempCells."Item No.", ItemTotal) then
|
||||
TotalsByItem.Set(TempCells."Item No.", ItemTotal + TempCells.Quantity)
|
||||
else
|
||||
TotalsByItem.Add(TempCells."Item No.", TempCells.Quantity);
|
||||
until TempCells.Next() = 0;
|
||||
|
||||
if TempCells.FindSet() then
|
||||
repeat
|
||||
DisplayedTotal += TotalsByItem.Get(TempCells."Item No.");
|
||||
until TempCells.Next() = 0;
|
||||
end;
|
||||
}
|
||||
|
|
@ -15,8 +15,12 @@ A temporary table stores its rows in Business Central Server memory instead of a
|
|||
|
||||
## Best Practice
|
||||
|
||||
Do not apply SQL-specific findings such as missing `SetLoadFields`, lock contention, or N+1 database round-trips to a temporary record. Still assess memory volume and repeated scans or lookups. For a pure key-to-value collection, consider an AL `Dictionary`; keep a temporary table when record fields, keys, filtering, or ordered iteration are required.
|
||||
Do not apply SQL-specific findings such as missing `SetLoadFields`, lock contention, or N+1 database round-trips to a temporary record. Still assess memory volume and repeated scans or lookups. In a matrix, if each cell re-sums the same group, calculate the totals once at the complete group key and reuse them; invalidate or adjust totals if cells change. For a pure key-to-value collection, consider an AL `Dictionary`; keep a temporary table when record fields, keys, filtering, or ordered iteration are required. Measure AL time and peak memory, not just SQL time.
|
||||
|
||||
See sample: [`temporary-tables-have-no-database-cost.good.al`](temporary-tables-have-no-database-cost.good.al).
|
||||
|
||||
## Anti Pattern
|
||||
|
||||
Claiming that every temporary-table access pattern is free because no SQL is involved. A nested scan over a large in-memory buffer can still dominate service-tier CPU, while adding `SetLoadFields` to that buffer addresses a database cost that does not exist.
|
||||
|
||||
See sample: [`temporary-tables-have-no-database-cost.bad.al`](temporary-tables-have-no-database-cost.bad.al).
|
||||
|
|
|
|||
|
|
@ -15,12 +15,12 @@ application-area: [all]
|
|||
|
||||
## 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`).
|
||||
Call `SetAutoCalcFields` before `FindSet` when every returned row needs the same FlowField for a comparison, branch, or per-record action. For one total of a **stored source field**, consider `CalcSums` instead (see [stored-field totals versus FlowFields](calcsums-instead-of-calcfields-in-loop.md)). If the required result is a total of selected FlowField values, derive the underlying source filters from `CalcFormula`; do not sum the FlowField directly with `CalcSums`.
|
||||
|
||||
See sample: [`use-setautocalcfields-for-per-row-flowfields.good.al`](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.
|
||||
Calling `CalcFields` inside the loop when every iteration reads the same FlowField. A compatible recent result can be cached, so do not equate each call with a SQL statement. 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`](use-setautocalcfields-for-per-row-flowfields.bad.al).
|
||||
|
|
|
|||
|
|
@ -1,14 +1,18 @@
|
|||
codeunit 50219 "Perf Sample LoadFields Bad"
|
||||
{
|
||||
procedure ListUSCustomerNames()
|
||||
procedure CollectUSCustomerNamesAndCities(var DisplayNames: List of [Text])
|
||||
var
|
||||
Customer: Record Customer;
|
||||
begin
|
||||
// Loads every Customer column on every row, when only Name is read.
|
||||
Customer.SetRange("Country/Region Code", 'US');
|
||||
if Customer.FindSet() then
|
||||
repeat
|
||||
Message(Customer.Name);
|
||||
DisplayNames.Add(CustomerDisplayText(Customer));
|
||||
until Customer.Next() = 0;
|
||||
end;
|
||||
|
||||
local procedure CustomerDisplayText(Customer: Record Customer): Text
|
||||
begin
|
||||
exit(Customer.Name + ' ' + Customer.City);
|
||||
end;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,17 +1,22 @@
|
|||
codeunit 50218 "Perf Sample LoadFields Good"
|
||||
{
|
||||
procedure ListUSCustomerNames()
|
||||
procedure CollectUSCustomerNamesAndCities(var DisplayNames: List of [Text])
|
||||
var
|
||||
Customer: Record Customer;
|
||||
begin
|
||||
Customer.SetRange("Country/Region Code", 'US');
|
||||
Customer.SetLoadFields(Name);
|
||||
Customer.SetLoadFields(Name, City);
|
||||
if Customer.FindSet() then
|
||||
repeat
|
||||
Message(Customer.Name);
|
||||
DisplayNames.Add(CustomerDisplayText(Customer));
|
||||
until Customer.Next() = 0;
|
||||
end;
|
||||
|
||||
local procedure CustomerDisplayText(Customer: Record Customer): Text
|
||||
begin
|
||||
exit(Customer.Name + ' ' + Customer.City);
|
||||
end;
|
||||
|
||||
procedure LookupSkuPolicy(LocationCode: Code[10]) Policy: Enum "SKU Creation Method"
|
||||
var
|
||||
Location: Record Location;
|
||||
|
|
|
|||
|
|
@ -17,7 +17,7 @@ Its position relative to `SetRange`/`SetFilter` does not change the projection:
|
|||
|
||||
## Best Practice
|
||||
|
||||
Before a `Get`, `FindSet`, or `FindFirst` that the procedure follows by reading only a handful of the table's fields, call `SetLoadFields` listing exactly those fields. For example, `SetLoadFields(...); if Record.Get(...) then ...` selects fields before the read. Place the call immediately before the read, after any `SetRange`/`SetFilter`, so a reader can see at a glance which read the selection governs and any projection-changing operation is easy to spot. Skip `SetLoadFields` when the table has few fields (under ten), when the code reads most of them (above 60 %), when the loop runs ten or fewer iterations, or when the table is exempt for other reasons ([singleton setup tables](singleton-setup-tables-need-no-access-optimization.md), [temporary tables](temporary-tables-have-no-database-cost.md)). The numeric cutoffs are BCQuality review heuristics, not Microsoft platform thresholds. For report dataitems, use `AddLoadFields` in `OnPreDataItem` instead (see [report partial loads](addloadfields-in-report-onpredataitem.md)).
|
||||
Before a `Get`, `FindSet`, or `FindFirst` that the complete read path follows by reading only a handful of the table's fields, call `SetLoadFields` listing the normal fields used by the caller **and its helpers**. For example, `SetLoadFields(...); if Record.Get(...) then ...` selects fields before the read. Place the call immediately before the read, after any `SetRange`/`SetFilter`, so a reader can see at a glance which read the selection governs and any projection-changing operation is easy to spot. Check for later `Reset` and for unloaded fields read by a [by-value helper](pass-var-record-to-preserve-partial-load-enumerator.md); a JIT load can be served from cache, so it is not automatically a SQL round trip. Skip `SetLoadFields` when the table has few fields (under ten), when the code reads most of them (above 60 %), when the loop runs ten or fewer iterations, or when the table is exempt for other reasons ([singleton setup tables](singleton-setup-tables-need-no-access-optimization.md), [temporary tables](temporary-tables-have-no-database-cost.md)). The numeric cutoffs are BCQuality review heuristics, not Microsoft platform thresholds. For report dataitems, use `AddLoadFields` in `OnPreDataItem` instead (see [report partial loads](addloadfields-in-report-onpredataitem.md)).
|
||||
|
||||
See sample: [`use-setloadfields-for-partial-records.good.al`](use-setloadfields-for-partial-records.good.al).
|
||||
|
||||
|
|
|
|||
|
|
@ -39,13 +39,16 @@ Narrow the relevant files to the subset that applies to the changes under review
|
|||
|
||||
- The changed AL object names and types — especially tables, pages with SourceTable bindings, reports, queries, and codeunits performing record iteration.
|
||||
- The changed procedures and triggers, weighted toward those that perform loops, Find/FindSet/FindFirst calls, CalcFields, SetAutoCalcFields, CalcSums, FlowField access, Commit calls, checkpoint helpers, record copying, RecordRef conversion, Modify/Delete calls, or cross-table navigation.
|
||||
- Tokens extracted from the diff that relate to data access, hot-path costs, and background scheduling (`SetRange`, `SetFilter`, `SetLoadFields`, `SetCurrentKey`, `FindSet`, `ReadIsolation`, `LockTable`, `ModifyAll`, `DeleteAll`, `Modify`, `Delete`, `Commit`, `checkpoint`, `Copy`, `RecordRef`, `GetTable`, `TextBuilder`, `Dictionary`, `temporary`, `repeat`, `until`, `CalcFields`, `SetAutoCalcFields`, `CalcSums`, `FlowField`, `Visible`, `Job Queue Entry`, `Job Queue Category Code`, `Confirm`, `RunModal`, `GuiAllowed`, `TryFunction`, `Codeunit.Run`, `HttpClient`, `Status`, `On Hold`, `stop request`, `TaskScheduler.CreateTask`, `TaskScheduler.TaskExists`, `Page.RunModal`, `Report.RunModal`, `Report.Run`, `Xmlport.Run`, `UseRequestPage`).
|
||||
- Tokens extracted from the diff that relate to data access, hot-path costs, and background scheduling (`key`, `IncludedFields`, `SumIndexFields`, `SetRange`, `SetFilter`, `SetLoadFields`, `SetCurrentKey`, `FindSet`, `FindLast`, `IsEmpty`, `ReadIsolation`, `LockTable`, `Insert`, `ModifyAll`, `DeleteAll`, `Modify`, `Delete`, `Validate`, `Commit`, `checkpoint`, `Copy`, `RecordRef`, `GetTable`, `TextBuilder`, `Dictionary`, `temporary`, `repeat`, `until`, `CalcFields`, `SetAutoCalcFields`, `CalcSums`, `CalcFormula`, `FlowField`, `Query.Open`, `Query.Read`, `Visible`, `Job Queue Entry`, `Job Queue Category Code`, `Confirm`, `RunModal`, `GuiAllowed`, `TryFunction`, `Codeunit.Run`, `HttpClient`, `Status`, `On Hold`, `stop request`, `TaskScheduler.CreateTask`, `TaskScheduler.TaskExists`, `Page.RunModal`, `Report.RunModal`, `Report.Run`, `Xmlport.Run`, `UseRequestPage`).
|
||||
|
||||
A file enters the candidate worklist when its `keywords` intersect the extracted tokens or its topic (derived from the index entry's `path`, `title`, and `description`) matches a changed object type. Read an article's full file — its `## Best Practice` / `## Anti Pattern` bodies — only after it makes the worklist; candidate selection uses the index alone.
|
||||
|
||||
Apply these targeted cues even when simple token overlap would rank the article below the worklist cutoff:
|
||||
|
||||
- Worklist `use-setautocalcfields-for-per-row-flowfields.md` when a record loop calls `CalcFields`, or when every row reads the same FlowField for a comparison, branch, or per-record action. Worklist `calcsums-instead-of-calcfields-in-loop.md` instead when the loop only accumulates one set total.
|
||||
- Worklist `design-covering-keys-from-read-pattern.md` for a changed secondary key alongside a filtered reader of its table, and `review-overlapping-keys-before-adding-an-index.md` when added or changed keys have overlapping leading fields. A changed key alone is not a finding; read and write workloads determine whether either rule applies.
|
||||
- Worklist `preserve-buffered-inserts-by-separating-target-reads.md` when a loop calls `Insert` and interleaves operations on the insert target or `Commit`. Worklist `aggregate-before-persisting-intermediate-results.md` when repeated grouping calculations and persistent intermediate summaries appear in the same processing path. Do not infer either pattern from `Insert` or `CalcSums` alone.
|
||||
- Worklist `cache-repeated-filtered-results-with-explicit-scope.md` only when the code or workload establishes repeated **complete** lookup keys (for example, querying the same filtered set in multiple passes, or measured key reuse), or shows a cache that omits result-affecting inputs. A single loop over possibly distinct keys does not establish reuse or justify a cache finding. Worklist `avoid-repeating-unchanged-validation.md` for repeated `Validate` of the same field in one path; do not worklist it from a single validation call.
|
||||
- Worklist `use-setautocalcfields-for-per-row-flowfields.md` when a record loop calls `CalcFields`, or when every row reads the same FlowField for a comparison, branch, or per-record action. Also worklist `calcsums-instead-of-calcfields-in-loop.md` when the loop accumulates one set total: use `CalcSums` directly only for stored source fields, never directly on FlowFields; a FlowField total requires deriving equivalent source filters from its `CalcFormula`.
|
||||
- Worklist `hidden-flowfields-still-calculate-before-bc26-opt-in.md` when a page control directly sources a FlowField and sets `Visible = false` or a visibility expression. Suppress it when the target is known to have BC26's **Calculate only visible FlowFields** feature enabled, or when the FlowField is cheap and intentionally preloaded.
|
||||
- Worklist `avoid-commit-inside-loops.md` when `Commit()` is inside a record-iteration body or a checkpoint loop lacks persisted progress that excludes completed work on retry. Do not match a commit after a complete business unit when the same transaction persists a restart-safe watermark/state and errors propagate. Still match a full-tail `FindSet` with periodic commits as unbounded retrieval; restart safety does not make it `TOP X`.
|
||||
- Worklist `prefer-modifyall-over-per-row-modify.md` for a constant-assignment `Modify(false)` loop with no validation or per-row semantics. Worklist `triggers-and-media-field-regress-modifyall.md` when table trigger code, related subscribers, security filtering, `Media`/`MediaSet`, or companion fields affect a bulk path. A progress dialog does not generically exempt a loop; accept it only when the equivalent bulk call already falls back to individual operations and semantics are preserved.
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue