Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
page 50100 "CurrPage Update OAGR Bad"
{
PageType = List;
SourceTable = Customer;
ApplicationArea = All;

layout
{
area(content)
{
repeater(Rows)
{
field("No."; Rec."No.") { }
field(Name; Rec.Name) { }
}
}
}

trigger OnAfterGetRecord()
begin
// Update from OnAfterGetRecord re-enters the trigger on every row.
CurrPage.Update(false);
end;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
page 50100 "CurrPage Update OAGR Good"
{
PageType = List;
SourceTable = Customer;
ApplicationArea = All;

layout
{
area(content)
{
repeater(Rows)
{
field("No."; Rec."No.") { }
field(Warning; WarningText) { }
}
}
}

var
WarningText: Text[50];

trigger OnAfterGetRecord()
begin
WarningText := CopyStr(Rec.Name, 1, MaxStrLen(WarningText));
end;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
---
bc-version: [all]
domain: performance
keywords: [currpage-update, onaftergetrecord, list-page, scroll, refresh]
technologies: [al]
countries: [w1]
application-area: [all]
---

# Do not call CurrPage.Update inside OnAfterGetRecord

> Contributions welcome — open a PR to refine or extend this article.

## Description

`OnAfterGetRecord` on a list already runs once per visible row on scroll and refresh. `CurrPage.Update` asks the page to reload, which fires those triggers again. The result is a refresh loop or a stutter on every row paint. Official developer performance guidance lists `CurrPage.Update()` in `OnAfterGetRecord` next to `Modify` as work that must not live there. Sibling of `do-not-modify-in-onaftergetrecord.md` (writes); this file is the client refresh half.

## Best Practice

Put display-only results in page variables assigned in `OnAfterGetRecord` without calling `Update`. If the page must refresh after an action, call `CurrPage.Update(false)` from `OnAction` or `OnAfterGetCurrRecord` once, not per row.

See sample: `avoid-currpage-update-in-onaftergetrecord.good.al`.

## Anti Pattern

`trigger OnAfterGetRecord() begin ... CurrPage.Update(); end;` on a list. The signal is `CurrPage.Update` inside `OnAfterGetRecord` or `OnAfterGetCurrRecord` without an explicit user action.

See sample: `avoid-currpage-update-in-onaftergetrecord.bad.al`.
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
codeunit 50100 "Batch NoSeries Insert Bad"
{
procedure InsertDraftOrders(var Customer: Record Customer)
var
SalesHeader: Record "Sales Header";
SalesSetup: Record "Sales & Receivables Setup";
NoSeries: Codeunit "No. Series";
begin
SalesSetup.Get();
if Customer.FindSet() then
repeat
SalesHeader.Init();
SalesHeader."Document Type" := SalesHeader."Document Type"::Order;
// Per-row GetNextNo locks the number-series line every insert.
SalesHeader."No." := NoSeries.GetNextNo(SalesSetup."Order Nos.", WorkDate());
SalesHeader."Sell-to Customer No." := Customer."No.";
SalesHeader.Insert(true);
until Customer.Next() = 0;
end;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
codeunit 50100 "Batch NoSeries Insert Good"
{
procedure InsertDraftOrders(var Customer: Record Customer)
var
SalesHeader: Record "Sales Header";
SalesSetup: Record "Sales & Receivables Setup";
NoSeriesBatch: Codeunit "No. Series - Batch";
begin
SalesSetup.Get();
if Customer.FindSet() then
repeat
SalesHeader.Init();
SalesHeader."Document Type" := SalesHeader."Document Type"::Order;
SalesHeader."No." := NoSeriesBatch.GetNextNo(SalesSetup."Order Nos.", WorkDate());
SalesHeader."Sell-to Customer No." := Customer."No.";
SalesHeader.Insert(true);
until Customer.Next() = 0;
NoSeriesBatch.SaveState();
end;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
---
bc-version: [22..]
domain: performance
keywords: [no-series, getnextno, no-series-batch, savestate, numbersequence, lock]
technologies: [al]
countries: [w1]
application-area: [all]
---

# Batch number-series calls instead of GetNextNo per insert

> Contributions welcome — open a PR to refine or extend this article.

## Description

`Codeunit "No. Series".GetNextNo` updates and locks the number-series line on every call. A tight `Insert` loop that asks for a number per row serializes every concurrent writer on that series — the classic SaaS posting bottleneck. Training data still copies the per-row C/AL `NoSeriesManagement` shape. Codeunit `"No. Series - Batch"` issues numbers in memory and writes the series line once via `SaveState`. `NumberSequence` is the alternative when gaps are acceptable.

## Best Practice

Inside a multi-row insert, call `"No. Series - Batch".GetNextNo` per row and `SaveState` once after the loop when the series must remain gapless. Use `NumberSequence.Next` when holes are allowed. Do not replace a single `OnInsert` `GetNextNo` for one master record; that path is not the hotspot.

See sample: `batch-number-series-instead-of-getnextno-per-row.good.al`.

## Anti Pattern

`NoSeries.GetNextNo(...)` inside `repeat ... Insert ... until Next() = 0`. Each iteration takes the series lock. The signal is `"No. Series"` (not `"No. Series - Batch"`) in a loop that inserts more than one row.

See sample: `batch-number-series-instead-of-getnextno-per-row.bad.al`.
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
codeunit 50100 "ChangeCompany Loop Bad"
{
procedure NamesForCustomers(var Buffer: Record Customer)
var
Customer: Record Customer;
Company: Record Company;
begin
if Buffer.FindSet() then
repeat
if Company.FindSet() then
repeat
// ChangeCompany per customer per company resets caches every row.
Customer.ChangeCompany(Company.Name);
if Customer.Get(Buffer."No.") then
Message(Customer.Name);
until Company.Next() = 0;
until Buffer.Next() = 0;
end;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
codeunit 50100 "ChangeCompany Loop Good"
{
procedure NameInCompany(CompanyNameValue: Text[30]; CustomerNo: Code[20]): Text
var
Customer: Record Customer;
begin
Customer.ChangeCompany(CompanyNameValue);
Customer.SetLoadFields(Name);
if Customer.Get(CustomerNo) then
exit(Customer.Name);
end;

procedure NamesForCompanies(var Company: Record Company)
var
Customer: Record Customer;
begin
if Company.FindSet() then
repeat
Customer.ChangeCompany(Company.Name);
Customer.SetLoadFields(Name);
if Customer.FindFirst() then
Message(Customer.Name);
until Company.Next() = 0;
end;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
---
bc-version: [all]
domain: performance
keywords: [changecompany, loop, cache, multi-company, isolation]
technologies: [al]
countries: [w1]
application-area: [all]
---

# Do not call ChangeCompany inside a per-row loop

> Contributions welcome — open a PR to refine or extend this article.

## Description

`ChangeCompany` retargets a record variable to another company's data and drops the in-memory caches bound to the previous company. Calling it once per row in a multi-company scan therefore pays a cache reset on every iteration, even when consecutive rows share a company. Agents treat `ChangeCompany` like a filter. It is an isolation switch.

## Best Practice

Group work by company. Call `ChangeCompany` once per distinct company, then `FindSet`/`Get` that company's rows. Reset the variable back when the batch finishes.

See sample: `changecompany-in-loop-drops-caches.good.al`.

## Anti Pattern

`repeat Rec.ChangeCompany(Buffer.Company); Rec.Get(Buffer."No."); until Buffer.Next() = 0` when `Buffer` is not ordered by company, or even when it is — if `ChangeCompany` still runs every row. The signal is `ChangeCompany` inside `repeat`/`while` keyed by a document line rather than by a company loop.

See sample: `changecompany-in-loop-drops-caches.bad.al`.
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
codeunit 50100 "CountApprox Progress Bad"
{
procedure RecalcUsCustomers()
var
Customer: Record Customer;
Window: Dialog;
Counter: Integer;
Total: Integer;
begin
Customer.SetRange("Country/Region Code", 'US');
// Exact Count() is a SELECT COUNT(*) just to drive a progress bar.
Total := Customer.Count();
Window.Open('Processing #1###### of #2######');
if Customer.FindSet(true) then
repeat
Counter += 1;
Window.Update(1, Counter);
Window.Update(2, Total);
until Customer.Next() = 0;
Window.Close();
end;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
codeunit 50100 "CountApprox Progress Good"
{
procedure RecalcUsCustomers()
var
Customer: Record Customer;
Window: Dialog;
Counter: Integer;
Total: Integer;
begin
Customer.SetRange("Country/Region Code", 'US');
Total := Customer.CountApprox();
Window.Open('Processing #1###### of #2######');
if Customer.FindSet(true) then
repeat
Counter += 1;
Window.Update(1, Counter);
Window.Update(2, Total);
until Customer.Next() = 0;
Window.Close();
end;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
---
bc-version: [all]
domain: performance
keywords: [countapprox, count, dialog, progress-bar, approximate-count]
technologies: [al]
countries: [w1]
application-area: [all]
---

# Use CountApprox for progress UI, not Count

> Contributions welcome — open a PR to refine or extend this article.

## Description

`Count()` asks SQL for an exact row count of the current filter. On a large table that is a `SELECT COUNT(*)` before any useful work starts — the usual cost of `Dialog.Open` with a percentage bar. `CountApprox()` exists for that UI case: it returns a cheap estimate (partition stats / metadata), accurate enough for a progress denominator. Agents default to `Count()` because the name matches "how many rows".
Comment on lines +9 to +16

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Are you sure about CountApprox? It is used in BCApps only 14 times, and not once for a progress bar.
If I remember correctly (but I am really not sure), it was said years ago that it does not provide that much value anymore. But it used to be important and useful in the past.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Count() executes a precise database scan to return the exact number of records, whereas CountApprox() pulls from SQL Server statistics to return an estimated number significantly faster. In Progress Bar (UI) on large tables it’s faster. But yes, we’re talking about ms on rare cases now. The rule can also not be mandatory.


## Best Practice

Feed progress dialogs and informational messages with `CountApprox()`. Use `Count()` only when the exact integer is a business result (a posted control, a reconciliation, a test assertion).

See sample: `countapprox-for-progress-not-count.good.al`.

## Anti Pattern

`Total := Rec.Count(); Window.Open(...);` immediately before a `FindSet` over the same filter. The exact count is discarded after the bar finishes; the user paid a full scan to draw it.

See sample: `countapprox-for-progress-not-count.bad.al`.
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
report 50100 "Cust List ReadOnly Bad"
{
UsageCategory = ReportsAndAnalysis;
ApplicationArea = All;
// Missing DataAccessIntent = ReadOnly; the scan hits the primary replica.

dataset
{
dataitem(Customer; Customer)
{
column(No; "No.") { }
column(Name; Name) { }
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
report 50100 "Cust List ReadOnly Good"
{
UsageCategory = ReportsAndAnalysis;
ApplicationArea = All;
DataAccessIntent = ReadOnly;

dataset
{
dataitem(Customer; Customer)
{
column(No; "No.") { }
column(Name; Name) { }
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
---
bc-version: [all]
domain: performance
keywords: [dataaccessintent, read-only, read-scale-out, report, api-page, query]
technologies: [al]
countries: [w1]
application-area: [all]
---

# Set DataAccessIntent ReadOnly on analytical objects

> Contributions welcome — open a PR to refine or extend this article.

## Description

Reports, API pages, and queries that only read can run against a read replica when `DataAccessIntent = ReadOnly`. Without the property they hit the primary replica and compete with posting. Agents omit it because the default is read-write and the object "only reads" in AL. The replica routing is a metadata switch, not something the compiler infers from the absence of `Modify`.

## Best Practice

On report, query, and API page objects that never write, set `DataAccessIntent = ReadOnly`. Keep the default on objects that insert, modify, or call a write codeunit from a processing-only report.

See sample: `dataaccessintent-readonly-on-analytical-objects.good.al`.

## Anti Pattern

A listing report or API query with no `DataAccessIntent` that scans G/L or sales lines. The object is read-only in practice and still loads the primary.

See sample: `dataaccessintent-readonly-on-analytical-objects.bad.al`.
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
page 50100 "GuiAllowed OData Guard Bad"
{
PageType = List;
SourceTable = Customer;
ApplicationArea = All;

layout
{
area(content)
{
repeater(Rows)
{
field("No."; Rec."No.") { }
field(Name; Rec.Name) { }
}
}
}

trigger OnAfterGetRecord()
begin
// Runs for every OData / Edit-in-Excel row with no UI.
Rec.CalcFields("Balance (LCY)");
end;
}
Loading