fix(community/performance): address review feedback

This commit is contained in:
Stefano Demiliani 2026-08-24 16:06:07 +02:00
parent 8373bc4717
commit 6a75a425af
13 changed files with 79 additions and 165 deletions

View file

@ -5,6 +5,7 @@ codeunit 50100 "ChangeCompany Loop Bad"
Customer: Record Customer;
Company: Record Company;
begin
Customer.SetLoadFields(Name);
if Buffer.FindSet() then
repeat
if Company.FindSet() then

View file

@ -1,25 +1,19 @@
codeunit 50100 "ChangeCompany Loop Good"
{
procedure NameInCompany(CompanyNameValue: Text[30]; CustomerNo: Code[20]): Text
procedure NamesForCustomers(var Buffer: Record Customer)
var
Customer: Record Customer;
Company: Record Company;
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);
if Buffer.FindSet() then
repeat
if Customer.Get(Buffer."No.") then
Message(Customer.Name);
until Buffer.Next() = 0;
until Company.Next() = 0;
end;
}

View file

@ -17,7 +17,7 @@ application-area: [all]
## 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.
Group work by company. Call `ChangeCompany` once per distinct company, then `FindSet`/`Get` that company's rows. If the record variable is reused afterward, call `ChangeCompany()` without a company name to redirect it back to the current company.
See sample: `changecompany-in-loop-drops-caches.good.al`.

View file

@ -1,22 +0,0 @@
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() then
repeat
Counter += 1;
Window.Update(1, Counter);
Window.Update(2, Total);
until Customer.Next() = 0;
Window.Close();
end;
}

View file

@ -1,21 +0,0 @@
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() then
repeat
Counter += 1;
Window.Update(1, Counter);
Window.Update(2, Total);
until Customer.Next() = 0;
Window.Close();
end;
}

View file

@ -1,28 +0,0 @@
---
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. When no SIFT key covers all filtered fields, this is a `SELECT COUNT(*)` against the data rows before any useful work starts — the usual cost of `Dialog.Open` with a percentage bar. (A filtered count on a table with a matching SIFT key is cheap, but SIFT coverage cannot be assumed for arbitrary filters.) `CountApprox()` exists for the progress-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".
## 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; when the filter is not covered by a SIFT key, the user paid a full table scan just to draw the progress bar.
See sample: `countapprox-for-progress-not-count.bad.al`.

View file

@ -11,14 +11,24 @@ page 50100 "GuiAllowed OData Guard Bad"
repeater(Rows)
{
field("No."; Rec."No.") { }
field(Name; Rec.Name) { }
field(Name; Rec.Name)
{
StyleExpr = NameStyle;
}
}
}
}
var
NameStyle: Text;
trigger OnAfterGetRecord()
begin
// Runs for every OData / Edit-in-Excel row with no UI.
// UI-only styling still runs for every OData / Edit-in-Excel row.
Rec.CalcFields("Balance (LCY)");
if Rec."Balance (LCY)" > 0 then
NameStyle := 'Attention'
else
NameStyle := 'Standard';
end;
}

View file

@ -11,15 +11,25 @@ page 50100 "GuiAllowed OData Guard Good"
repeater(Rows)
{
field("No."; Rec."No.") { }
field(Name; Rec.Name) { }
field(Name; Rec.Name)
{
StyleExpr = NameStyle;
}
}
}
}
var
NameStyle: Text;
trigger OnAfterGetRecord()
begin
if not GuiAllowed then
exit;
Rec.CalcFields("Balance (LCY)");
if Rec."Balance (LCY)" > 0 then
NameStyle := 'Attention'
else
NameStyle := 'Standard';
end;
}

View file

@ -1,25 +1,62 @@
codeunit 50100 "HttpClient Holds Locks Good"
{
// Write completes inside the caller's transaction; HTTP deferred so locks are released with it.
procedure SyncCustomerLastName(var Customer: Record Customer)
var
CustomerSyncOutbox: Record "Customer Sync Outbox";
begin
Customer."Search Name" := Customer.Name;
Customer.Modify(false);
// RecordId binds the task to this specific customer; the platform loads it into Rec on OnRun.
TaskScheduler.CreateTask(Codeunit::"Customer Sync Task", 0, true, CompanyName(), CurrentDateTime(), Customer.RecordId);
// This work item commits or rolls back with the customer change.
CustomerSyncOutbox."Customer No." := Customer."No.";
CustomerSyncOutbox.Insert();
end;
}
codeunit 50101 "Customer Sync Task"
table 50100 "Customer Sync Outbox"
{
TableNo = Customer;
DataClassification = CustomerContent;
fields
{
field(1; "Entry No."; Integer)
{
AutoIncrement = true;
}
field(2; "Customer No."; Code[20]) { }
}
keys
{
key(PK; "Entry No.")
{
Clustered = true;
}
}
}
codeunit 50101 "Customer Sync Outbox Worker"
{
// Configure this codeunit as a recurring job queue entry.
TableNo = "Job Queue Entry";
trigger OnRun()
var
Customer: Record Customer;
CustomerSyncOutbox: Record "Customer Sync Outbox";
Client: HttpClient;
Response: HttpResponseMessage;
begin
// Separate session: Rec is the single customer passed via RecordId; no write-transaction lock is held.
Client.Get(StrSubstNo('https://example.local/sync/%1', Rec."No."), Response);
// Only committed work is visible here; a rolled-back change leaves no outbox row.
if not CustomerSyncOutbox.FindFirst() then
exit;
Customer.Get(CustomerSyncOutbox."Customer No.");
Client.Get(StrSubstNo('https://example.local/sync/%1', Customer."No."), Response);
if not Response.IsSuccessStatusCode() then
Error('Customer sync failed with HTTP status %1.', Response.HttpStatusCode());
// Delete only after HTTP completes, so no write lock is held during the call.
CustomerSyncOutbox.Delete();
end;
}

View file

@ -17,7 +17,9 @@ The first database write opens an AL write transaction that the runtime holds un
## Best Practice
Defer the HTTP call to a `TaskScheduler` task or job queue entry so it runs in a separate session after the write transaction has already ended. The database write completes and releases its locks naturally when the caller's transaction commits; the HTTP call then happens without holding any locks. Do **not** use `Commit()` as a general remedy: it irrevocably commits all prior writes in the current transaction, so any subsequent failure cannot roll them back. `Commit()` is appropriate only at top-level entry points where partial persistence is intentional and understood.
Defer the HTTP call to a separate session. When the external operation must correspond to a committed database change, insert an outbox work item in the same transaction as that change and process committed outbox rows with a recurring job queue entry. The change and work item then commit or roll back together, and the worker performs HTTP before deleting the item so it holds no write lock during the call. Make the external operation idempotent because a failure after a successful HTTP response can cause the work item to be retried.
A directly created scheduled task is suitable only when its work is independent of the caller's commit. An immediately ready task can run concurrently with the caller, so it must not assume that the caller's writes are already committed. Do **not** use `Commit()` as a general remedy: it irrevocably commits all prior writes in the current transaction, so any subsequent failure cannot roll them back. `Commit()` is appropriate only at top-level entry points where partial persistence is intentional and understood.
See sample: `httpclient-inside-write-transaction-holds-locks.good.al`.

View file

@ -1,23 +0,0 @@
page 50100 "Visible False Page Cost Bad"
{
PageType = List;
SourceTable = Customer;
ApplicationArea = All;
layout
{
area(content)
{
repeater(Rows)
{
field("No."; Rec."No.") { }
field(Name; Rec.Name) { }
// Hidden still participates in page load / FlowField calculation.
field("Balance (LCY)"; Rec."Balance (LCY)")
{
Visible = false;
}
}
}
}
}

View file

@ -1,18 +0,0 @@
page 50100 "Visible False Page Cost Good"
{
PageType = List;
SourceTable = Customer;
ApplicationArea = All;
layout
{
area(content)
{
repeater(Rows)
{
field("No."; Rec."No.") { }
field(Name; Rec.Name) { }
}
}
}
}

View file

@ -1,28 +0,0 @@
---
bc-version: [all]
domain: performance
keywords: [visible, enabled, page-field, list-page, metadata, hidden-control]
technologies: [al]
countries: [w1]
application-area: [all]
---
# Visible false does not skip page-field cost
> Contributions welcome — open a PR to refine or extend this article.
## Description
`Visible = false` and `Enabled = false` hide a control; they do not remove it from the page metadata the client and server still process. List pages in particular still load bound fields and can still calculate FlowFields on those controls — see `hidden-flowfields-still-calculate-before-bc26-opt-in.md` for the FlowField-specific opt-in. Official page-performance guidance is to **delete** the field from the page object when users do not need it. Agents hide heavy columns instead of removing them.
## Best Practice
If a list or card should not pay for a column, omit the field from the page (or page extension) layout. Use `Visible` only for controls that must exist for some users or modes and whose cost is acceptable when hidden. Do not treat `Visible = false` as a performance fix.
See sample: `visible-false-does-not-skip-page-field-cost.good.al`.
## Anti Pattern
Adding an expensive bound field or FlowField to a list and setting `Visible = false` "so it does not run". The control remains in the page definition. The signal is a hidden bound field whose only purpose was to avoid showing data, not to toggle a real mode.
See sample: `visible-false-does-not-skip-page-field-cost.bad.al`.