From ebceff2332fc19e9a036782a0ba1a1f855baea23 Mon Sep 17 00:00:00 2001 From: Stefano Demiliani Date: Thu, 20 Aug 2026 15:57:58 +0200 Subject: [PATCH 1/3] knowledge(performance): add community rules for JIT, locks, and false positives These articles capture BC-specific mechanics agents still invert: partial-record JIT on writes, Reset clearing SetLoadFields, HttpClient inside write transactions, and batched number series, plus negative guidance that stops over-eager Query and IsEmpty "fixes". Co-authored-by: Cursor --- ...currpage-update-in-onaftergetrecord.bad.al | 24 ++++++++ ...urrpage-update-in-onaftergetrecord.good.al | 26 +++++++++ ...oid-currpage-update-in-onaftergetrecord.md | 28 +++++++++ ...series-instead-of-getnextno-per-row.bad.al | 20 +++++++ ...eries-instead-of-getnextno-per-row.good.al | 20 +++++++ ...ber-series-instead-of-getnextno-per-row.md | 28 +++++++++ .../changecompany-in-loop-drops-caches.bad.al | 19 +++++++ ...changecompany-in-loop-drops-caches.good.al | 25 ++++++++ .../changecompany-in-loop-drops-caches.md | 28 +++++++++ .../countapprox-for-progress-not-count.bad.al | 22 +++++++ ...countapprox-for-progress-not-count.good.al | 21 +++++++ .../countapprox-for-progress-not-count.md | 28 +++++++++ ...tent-readonly-on-analytical-objects.bad.al | 15 +++++ ...ent-readonly-on-analytical-objects.good.al | 15 +++++ ...ssintent-readonly-on-analytical-objects.md | 28 +++++++++ ...llowed-guard-on-pages-used-as-odata.bad.al | 24 ++++++++ ...lowed-guard-on-pages-used-as-odata.good.al | 25 ++++++++ ...guiallowed-guard-on-pages-used-as-odata.md | 28 +++++++++ ...nside-write-transaction-holds-locks.bad.al | 13 +++++ ...side-write-transaction-holds-locks.good.al | 13 +++++ ...nt-inside-write-transaction-holds-locks.md | 28 +++++++++ ...-before-findset-is-extra-round-trip.bad.al | 16 ++++++ ...before-findset-is-extra-round-trip.good.al | 14 +++++ ...mpty-before-findset-is-extra-round-trip.md | 28 +++++++++ ...panyopen-subscribers-must-not-do-io.bad.al | 15 +++++ ...anyopen-subscribers-must-not-do-io.good.al | 17 ++++++ ...ncompanyopen-subscribers-must-not-do-io.md | 28 +++++++++ ...background-tasks-for-expensive-cues.bad.al | 31 ++++++++++ ...ackground-tasks-for-expensive-cues.good.al | 49 ++++++++++++++++ ...age-background-tasks-for-expensive-cues.md | 28 +++++++++ ...to-preserve-partial-load-enumerator.bad.al | 20 +++++++ ...o-preserve-partial-load-enumerator.good.al | 21 +++++++ ...ord-to-preserve-partial-load-enumerator.md | 28 +++++++++ ...table-over-extension-on-hot-ledgers.bad.al | 9 +++ ...able-over-extension-on-hot-ledgers.good.al | 19 +++++++ ...ted-table-over-extension-on-hot-ledgers.md | 28 +++++++++ ...ry-results-bypass-primary-key-cache.bad.al | 28 +++++++++ ...y-results-bypass-primary-key-cache.good.al | 12 ++++ .../query-results-bypass-primary-key-cache.md | 28 +++++++++ ...set-clears-partial-record-selection.bad.al | 16 ++++++ ...et-clears-partial-record-selection.good.al | 15 +++++ .../reset-clears-partial-record-selection.md | 28 +++++++++ ...dfields-on-write-and-transferfields.bad.al | 16 ++++++ ...fields-on-write-and-transferfields.good.al | 15 +++++ ...tloadfields-on-write-and-transferfields.md | 28 +++++++++ ...dicated-lookup-pages-not-full-lists.bad.al | 43 ++++++++++++++ ...icated-lookup-pages-not-full-lists.good.al | 57 +++++++++++++++++++ ...e-dedicated-lookup-pages-not-full-lists.md | 28 +++++++++ ...lidate-on-partial-record-forces-jit.bad.al | 16 ++++++ ...idate-on-partial-record-forces-jit.good.al | 14 +++++ .../validate-on-partial-record-forces-jit.md | 28 +++++++++ ...false-does-not-skip-page-field-cost.bad.al | 23 ++++++++ ...alse-does-not-skip-page-field-cost.good.al | 18 ++++++ ...ble-false-does-not-skip-page-field-cost.md | 28 +++++++++ 54 files changed, 1270 insertions(+) create mode 100644 community/knowledge/performance/avoid-currpage-update-in-onaftergetrecord.bad.al create mode 100644 community/knowledge/performance/avoid-currpage-update-in-onaftergetrecord.good.al create mode 100644 community/knowledge/performance/avoid-currpage-update-in-onaftergetrecord.md create mode 100644 community/knowledge/performance/batch-number-series-instead-of-getnextno-per-row.bad.al create mode 100644 community/knowledge/performance/batch-number-series-instead-of-getnextno-per-row.good.al create mode 100644 community/knowledge/performance/batch-number-series-instead-of-getnextno-per-row.md create mode 100644 community/knowledge/performance/changecompany-in-loop-drops-caches.bad.al create mode 100644 community/knowledge/performance/changecompany-in-loop-drops-caches.good.al create mode 100644 community/knowledge/performance/changecompany-in-loop-drops-caches.md create mode 100644 community/knowledge/performance/countapprox-for-progress-not-count.bad.al create mode 100644 community/knowledge/performance/countapprox-for-progress-not-count.good.al create mode 100644 community/knowledge/performance/countapprox-for-progress-not-count.md create mode 100644 community/knowledge/performance/dataaccessintent-readonly-on-analytical-objects.bad.al create mode 100644 community/knowledge/performance/dataaccessintent-readonly-on-analytical-objects.good.al create mode 100644 community/knowledge/performance/dataaccessintent-readonly-on-analytical-objects.md create mode 100644 community/knowledge/performance/guiallowed-guard-on-pages-used-as-odata.bad.al create mode 100644 community/knowledge/performance/guiallowed-guard-on-pages-used-as-odata.good.al create mode 100644 community/knowledge/performance/guiallowed-guard-on-pages-used-as-odata.md create mode 100644 community/knowledge/performance/httpclient-inside-write-transaction-holds-locks.bad.al create mode 100644 community/knowledge/performance/httpclient-inside-write-transaction-holds-locks.good.al create mode 100644 community/knowledge/performance/httpclient-inside-write-transaction-holds-locks.md create mode 100644 community/knowledge/performance/isempty-before-findset-is-extra-round-trip.bad.al create mode 100644 community/knowledge/performance/isempty-before-findset-is-extra-round-trip.good.al create mode 100644 community/knowledge/performance/isempty-before-findset-is-extra-round-trip.md create mode 100644 community/knowledge/performance/oncompanyopen-subscribers-must-not-do-io.bad.al create mode 100644 community/knowledge/performance/oncompanyopen-subscribers-must-not-do-io.good.al create mode 100644 community/knowledge/performance/oncompanyopen-subscribers-must-not-do-io.md create mode 100644 community/knowledge/performance/page-background-tasks-for-expensive-cues.bad.al create mode 100644 community/knowledge/performance/page-background-tasks-for-expensive-cues.good.al create mode 100644 community/knowledge/performance/page-background-tasks-for-expensive-cues.md create mode 100644 community/knowledge/performance/pass-var-record-to-preserve-partial-load-enumerator.bad.al create mode 100644 community/knowledge/performance/pass-var-record-to-preserve-partial-load-enumerator.good.al create mode 100644 community/knowledge/performance/pass-var-record-to-preserve-partial-load-enumerator.md create mode 100644 community/knowledge/performance/prefer-related-table-over-extension-on-hot-ledgers.bad.al create mode 100644 community/knowledge/performance/prefer-related-table-over-extension-on-hot-ledgers.good.al create mode 100644 community/knowledge/performance/prefer-related-table-over-extension-on-hot-ledgers.md create mode 100644 community/knowledge/performance/query-results-bypass-primary-key-cache.bad.al create mode 100644 community/knowledge/performance/query-results-bypass-primary-key-cache.good.al create mode 100644 community/knowledge/performance/query-results-bypass-primary-key-cache.md create mode 100644 community/knowledge/performance/reset-clears-partial-record-selection.bad.al create mode 100644 community/knowledge/performance/reset-clears-partial-record-selection.good.al create mode 100644 community/knowledge/performance/reset-clears-partial-record-selection.md create mode 100644 community/knowledge/performance/skip-setloadfields-on-write-and-transferfields.bad.al create mode 100644 community/knowledge/performance/skip-setloadfields-on-write-and-transferfields.good.al create mode 100644 community/knowledge/performance/skip-setloadfields-on-write-and-transferfields.md create mode 100644 community/knowledge/performance/use-dedicated-lookup-pages-not-full-lists.bad.al create mode 100644 community/knowledge/performance/use-dedicated-lookup-pages-not-full-lists.good.al create mode 100644 community/knowledge/performance/use-dedicated-lookup-pages-not-full-lists.md create mode 100644 community/knowledge/performance/validate-on-partial-record-forces-jit.bad.al create mode 100644 community/knowledge/performance/validate-on-partial-record-forces-jit.good.al create mode 100644 community/knowledge/performance/validate-on-partial-record-forces-jit.md create mode 100644 community/knowledge/performance/visible-false-does-not-skip-page-field-cost.bad.al create mode 100644 community/knowledge/performance/visible-false-does-not-skip-page-field-cost.good.al create mode 100644 community/knowledge/performance/visible-false-does-not-skip-page-field-cost.md diff --git a/community/knowledge/performance/avoid-currpage-update-in-onaftergetrecord.bad.al b/community/knowledge/performance/avoid-currpage-update-in-onaftergetrecord.bad.al new file mode 100644 index 0000000..8fdd81f --- /dev/null +++ b/community/knowledge/performance/avoid-currpage-update-in-onaftergetrecord.bad.al @@ -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; +} diff --git a/community/knowledge/performance/avoid-currpage-update-in-onaftergetrecord.good.al b/community/knowledge/performance/avoid-currpage-update-in-onaftergetrecord.good.al new file mode 100644 index 0000000..9463162 --- /dev/null +++ b/community/knowledge/performance/avoid-currpage-update-in-onaftergetrecord.good.al @@ -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; +} diff --git a/community/knowledge/performance/avoid-currpage-update-in-onaftergetrecord.md b/community/knowledge/performance/avoid-currpage-update-in-onaftergetrecord.md new file mode 100644 index 0000000..4aafb22 --- /dev/null +++ b/community/knowledge/performance/avoid-currpage-update-in-onaftergetrecord.md @@ -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`. diff --git a/community/knowledge/performance/batch-number-series-instead-of-getnextno-per-row.bad.al b/community/knowledge/performance/batch-number-series-instead-of-getnextno-per-row.bad.al new file mode 100644 index 0000000..e17624e --- /dev/null +++ b/community/knowledge/performance/batch-number-series-instead-of-getnextno-per-row.bad.al @@ -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; +} diff --git a/community/knowledge/performance/batch-number-series-instead-of-getnextno-per-row.good.al b/community/knowledge/performance/batch-number-series-instead-of-getnextno-per-row.good.al new file mode 100644 index 0000000..236fef0 --- /dev/null +++ b/community/knowledge/performance/batch-number-series-instead-of-getnextno-per-row.good.al @@ -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; +} diff --git a/community/knowledge/performance/batch-number-series-instead-of-getnextno-per-row.md b/community/knowledge/performance/batch-number-series-instead-of-getnextno-per-row.md new file mode 100644 index 0000000..9d8bfe6 --- /dev/null +++ b/community/knowledge/performance/batch-number-series-instead-of-getnextno-per-row.md @@ -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`. diff --git a/community/knowledge/performance/changecompany-in-loop-drops-caches.bad.al b/community/knowledge/performance/changecompany-in-loop-drops-caches.bad.al new file mode 100644 index 0000000..f1246a6 --- /dev/null +++ b/community/knowledge/performance/changecompany-in-loop-drops-caches.bad.al @@ -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; +} diff --git a/community/knowledge/performance/changecompany-in-loop-drops-caches.good.al b/community/knowledge/performance/changecompany-in-loop-drops-caches.good.al new file mode 100644 index 0000000..e26a98f --- /dev/null +++ b/community/knowledge/performance/changecompany-in-loop-drops-caches.good.al @@ -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; +} diff --git a/community/knowledge/performance/changecompany-in-loop-drops-caches.md b/community/knowledge/performance/changecompany-in-loop-drops-caches.md new file mode 100644 index 0000000..816c0a5 --- /dev/null +++ b/community/knowledge/performance/changecompany-in-loop-drops-caches.md @@ -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`. diff --git a/community/knowledge/performance/countapprox-for-progress-not-count.bad.al b/community/knowledge/performance/countapprox-for-progress-not-count.bad.al new file mode 100644 index 0000000..83e029f --- /dev/null +++ b/community/knowledge/performance/countapprox-for-progress-not-count.bad.al @@ -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; +} diff --git a/community/knowledge/performance/countapprox-for-progress-not-count.good.al b/community/knowledge/performance/countapprox-for-progress-not-count.good.al new file mode 100644 index 0000000..d4c5f61 --- /dev/null +++ b/community/knowledge/performance/countapprox-for-progress-not-count.good.al @@ -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; +} diff --git a/community/knowledge/performance/countapprox-for-progress-not-count.md b/community/knowledge/performance/countapprox-for-progress-not-count.md new file mode 100644 index 0000000..d2b9a6f --- /dev/null +++ b/community/knowledge/performance/countapprox-for-progress-not-count.md @@ -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". + +## 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`. diff --git a/community/knowledge/performance/dataaccessintent-readonly-on-analytical-objects.bad.al b/community/knowledge/performance/dataaccessintent-readonly-on-analytical-objects.bad.al new file mode 100644 index 0000000..1ef1fe4 --- /dev/null +++ b/community/knowledge/performance/dataaccessintent-readonly-on-analytical-objects.bad.al @@ -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) { } + } + } +} diff --git a/community/knowledge/performance/dataaccessintent-readonly-on-analytical-objects.good.al b/community/knowledge/performance/dataaccessintent-readonly-on-analytical-objects.good.al new file mode 100644 index 0000000..07f22d0 --- /dev/null +++ b/community/knowledge/performance/dataaccessintent-readonly-on-analytical-objects.good.al @@ -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) { } + } + } +} diff --git a/community/knowledge/performance/dataaccessintent-readonly-on-analytical-objects.md b/community/knowledge/performance/dataaccessintent-readonly-on-analytical-objects.md new file mode 100644 index 0000000..9a245f5 --- /dev/null +++ b/community/knowledge/performance/dataaccessintent-readonly-on-analytical-objects.md @@ -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`. diff --git a/community/knowledge/performance/guiallowed-guard-on-pages-used-as-odata.bad.al b/community/knowledge/performance/guiallowed-guard-on-pages-used-as-odata.bad.al new file mode 100644 index 0000000..b12a764 --- /dev/null +++ b/community/knowledge/performance/guiallowed-guard-on-pages-used-as-odata.bad.al @@ -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; +} diff --git a/community/knowledge/performance/guiallowed-guard-on-pages-used-as-odata.good.al b/community/knowledge/performance/guiallowed-guard-on-pages-used-as-odata.good.al new file mode 100644 index 0000000..c638cf8 --- /dev/null +++ b/community/knowledge/performance/guiallowed-guard-on-pages-used-as-odata.good.al @@ -0,0 +1,25 @@ +page 50100 "GuiAllowed OData Guard Good" +{ + PageType = List; + SourceTable = Customer; + ApplicationArea = All; + + layout + { + area(content) + { + repeater(Rows) + { + field("No."; Rec."No.") { } + field(Name; Rec.Name) { } + } + } + } + + trigger OnAfterGetRecord() + begin + if not GuiAllowed then + exit; + Rec.CalcFields("Balance (LCY)"); + end; +} diff --git a/community/knowledge/performance/guiallowed-guard-on-pages-used-as-odata.md b/community/knowledge/performance/guiallowed-guard-on-pages-used-as-odata.md new file mode 100644 index 0000000..01dc1f8 --- /dev/null +++ b/community/knowledge/performance/guiallowed-guard-on-pages-used-as-odata.md @@ -0,0 +1,28 @@ +--- +bc-version: [all] +domain: performance +keywords: [guiallowed, clienttype, odata, edit-in-excel, page-trigger, factbox] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# Guard page trigger work with GuiAllowed for OData and Excel + +> Contributions welcome — open a PR to refine or extend this article. + +## Description + +Pages exposed as OData, including Edit in Excel, still run AL page triggers for every row returned. FactBox updates, defaulting, and extra `CalcFields` in `OnAfterGetRecord` therefore run on the web-service path where no UI exists. `GuiAllowed` is false for those sessions. Agents add page logic as if only the browser client will execute it. + +## Best Practice + +Wrap UI-only work — FactBox refresh, notifications, defaulting that is not part of the web-service contract — in `if GuiAllowed then`. Keep the OData path to field values the API actually returns. + +See sample: `guiallowed-guard-on-pages-used-as-odata.good.al`. + +## Anti Pattern + +Unconditional FactBox or calculation logic in `OnAfterGetRecord` / `OnAfterGetCurrRecord` on a page that is published as a web service or used with Edit in Excel. The signal is trigger work that calls `CurrPage` parts or extra queries without a `GuiAllowed` guard. + +See sample: `guiallowed-guard-on-pages-used-as-odata.bad.al`. diff --git a/community/knowledge/performance/httpclient-inside-write-transaction-holds-locks.bad.al b/community/knowledge/performance/httpclient-inside-write-transaction-holds-locks.bad.al new file mode 100644 index 0000000..8297ffe --- /dev/null +++ b/community/knowledge/performance/httpclient-inside-write-transaction-holds-locks.bad.al @@ -0,0 +1,13 @@ +codeunit 50100 "HttpClient Holds Locks Bad" +{ + procedure SyncCustomerLastName(var Customer: Record Customer) + var + Client: HttpClient; + Response: HttpResponseMessage; + begin + Customer."Search Name" := Customer.Name; + Customer.Modify(false); + // Locks from Modify are held for the entire HTTP wait. + Client.Get(StrSubstNo('https://example.local/sync/%1', Customer."No."), Response); + end; +} diff --git a/community/knowledge/performance/httpclient-inside-write-transaction-holds-locks.good.al b/community/knowledge/performance/httpclient-inside-write-transaction-holds-locks.good.al new file mode 100644 index 0000000..6136722 --- /dev/null +++ b/community/knowledge/performance/httpclient-inside-write-transaction-holds-locks.good.al @@ -0,0 +1,13 @@ +codeunit 50100 "HttpClient Holds Locks Good" +{ + procedure SyncCustomerLastName(var Customer: Record Customer) + var + Client: HttpClient; + Response: HttpResponseMessage; + begin + Customer."Search Name" := Customer.Name; + Customer.Modify(false); + Commit(); + Client.Get(StrSubstNo('https://example.local/sync/%1', Customer."No."), Response); + end; +} diff --git a/community/knowledge/performance/httpclient-inside-write-transaction-holds-locks.md b/community/knowledge/performance/httpclient-inside-write-transaction-holds-locks.md new file mode 100644 index 0000000..35ed210 --- /dev/null +++ b/community/knowledge/performance/httpclient-inside-write-transaction-holds-locks.md @@ -0,0 +1,28 @@ +--- +bc-version: [all] +domain: performance +keywords: [httpclient, write-transaction, lock, commit, outbound-http, session-block] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# Do not call HttpClient inside an open write transaction + +> Contributions welcome — open a PR to refine or extend this article. + +## Description + +The first database write opens an AL write transaction that the runtime holds until the execution completes or `Commit()` runs — see `understand-implicit-transaction-boundary.md`. `HttpClient` blocks the session until the remote call returns. Any locks taken by earlier `Insert`/`Modify`/`Delete` therefore stay held for the HTTP wall-clock time, and interactive users see a spinner. This is not generic "don't block": it is the AL transaction model plus lock lifetime around outbound I/O. + +## Best Practice + +Finish database writes and `Commit()` (or return from the write execution) before `HttpClient.Send`/`Get`/`Post`. If the call can be slow or retry, isolate it in a job queue or `TaskScheduler` task so UI and other sessions are not sitting on the writer's locks. + +See sample: `httpclient-inside-write-transaction-holds-locks.good.al`. + +## Anti Pattern + +`Modify`/`Insert` followed by `HttpClient` in the same procedure with no `Commit` between them. Detection signal: any `HttpClient` use after a write on the same execution path, especially in posting, page actions, or subscribers. + +See sample: `httpclient-inside-write-transaction-holds-locks.bad.al`. diff --git a/community/knowledge/performance/isempty-before-findset-is-extra-round-trip.bad.al b/community/knowledge/performance/isempty-before-findset-is-extra-round-trip.bad.al new file mode 100644 index 0000000..f11239e --- /dev/null +++ b/community/knowledge/performance/isempty-before-findset-is-extra-round-trip.bad.al @@ -0,0 +1,16 @@ +codeunit 50100 "IsEmpty Before FindSet Bad" +{ + procedure ListUsCustomerNames() + var + Customer: Record Customer; + begin + Customer.SetLoadFields(Name); + Customer.SetRange("Country/Region Code", 'US'); + // IsEmpty does not replace FindSet; it adds a second round-trip. + if not Customer.IsEmpty() then + if Customer.FindSet() then + repeat + Message(Customer.Name); + until Customer.Next() = 0; + end; +} diff --git a/community/knowledge/performance/isempty-before-findset-is-extra-round-trip.good.al b/community/knowledge/performance/isempty-before-findset-is-extra-round-trip.good.al new file mode 100644 index 0000000..e018fb5 --- /dev/null +++ b/community/knowledge/performance/isempty-before-findset-is-extra-round-trip.good.al @@ -0,0 +1,14 @@ +codeunit 50100 "IsEmpty Before FindSet Good" +{ + procedure ListUsCustomerNames() + var + Customer: Record Customer; + begin + Customer.SetLoadFields(Name); + Customer.SetRange("Country/Region Code", 'US'); + if Customer.FindSet() then + repeat + Message(Customer.Name); + until Customer.Next() = 0; + end; +} diff --git a/community/knowledge/performance/isempty-before-findset-is-extra-round-trip.md b/community/knowledge/performance/isempty-before-findset-is-extra-round-trip.md new file mode 100644 index 0000000..a8bf187 --- /dev/null +++ b/community/knowledge/performance/isempty-before-findset-is-extra-round-trip.md @@ -0,0 +1,28 @@ +--- +bc-version: [all] +domain: performance +keywords: [isempty, findset, extra-round-trip, existence-check, false-positive] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# IsEmpty immediately before FindSet is an extra round-trip + +> Contributions welcome — open a PR to refine or extend this article. + +## 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. + +## 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. + +See sample: `isempty-before-findset-is-extra-round-trip.good.al`. + +## Anti Pattern + +`if not Rec.IsEmpty() then if Rec.FindSet() then repeat`. Also a false-positive review comment that asks to add that guard. The second read does not avoid the first; it duplicates it. + +See sample: `isempty-before-findset-is-extra-round-trip.bad.al`. diff --git a/community/knowledge/performance/oncompanyopen-subscribers-must-not-do-io.bad.al b/community/knowledge/performance/oncompanyopen-subscribers-must-not-do-io.bad.al new file mode 100644 index 0000000..7d88fb7 --- /dev/null +++ b/community/knowledge/performance/oncompanyopen-subscribers-must-not-do-io.bad.al @@ -0,0 +1,15 @@ +codeunit 50100 "Login Subscriber IO Bad" +{ + [EventSubscriber(ObjectType::Codeunit, Codeunit::"System Initialization", OnAfterLogin, '', false, false)] + local procedure OnAfterLogin() + var + Client: HttpClient; + Response: HttpResponseMessage; + GLEntry: Record "G/L Entry"; + begin + // Blocks UI, API, and job-queue session creation until HTTP and SQL finish. + Client.Get('https://example.local/warmup', Response); + GLEntry.SetLoadFields("Entry No."); + if GLEntry.FindLast() then; + end; +} diff --git a/community/knowledge/performance/oncompanyopen-subscribers-must-not-do-io.good.al b/community/knowledge/performance/oncompanyopen-subscribers-must-not-do-io.good.al new file mode 100644 index 0000000..d23ca87 --- /dev/null +++ b/community/knowledge/performance/oncompanyopen-subscribers-must-not-do-io.good.al @@ -0,0 +1,17 @@ +codeunit 50100 "Login Subscriber IO Good" +{ + [EventSubscriber(ObjectType::Codeunit, Codeunit::"System Initialization", OnAfterLogin, '', false, false)] + local procedure OnAfterLogin() + begin + // Defer HTTP and heavy SQL to a job; session open must return immediately. + TaskScheduler.CreateTask(Codeunit::"Login Subscriber IO Work", Codeunit::"Login Subscriber IO Work", true, CompanyName(), CurrentDateTime()); + end; +} + +codeunit 50101 "Login Subscriber IO Work" +{ + trigger OnRun() + begin + // Isolated from session creation: outbound I/O is safe here. + end; +} diff --git a/community/knowledge/performance/oncompanyopen-subscribers-must-not-do-io.md b/community/knowledge/performance/oncompanyopen-subscribers-must-not-do-io.md new file mode 100644 index 0000000..3cb8459 --- /dev/null +++ b/community/knowledge/performance/oncompanyopen-subscribers-must-not-do-io.md @@ -0,0 +1,28 @@ +--- +bc-version: [all] +domain: performance +keywords: [oncompanyopen, onafterlogin, session-start, httpclient, subscriber, login] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# Session-open subscribers must not do I/O + +> Contributions welcome — open a PR to refine or extend this article. + +## Description + +`OnCompanyOpen`, `OnCompanyOpenCompleted`, and `System Initialization`.OnAfterLogin run while the session is being created. The platform waits until every subscriber returns before the UI, an API call, or a background session can proceed. `HttpClient` or a heavy `FindSet` here delays **every** session type, not just the user who "opened the company". Agents still put warmup sync, license checks, and HTTP probes on these events because they look like an application startup hook. + +## Best Practice + +Keep company-open subscribers to cheap in-memory work: set a flag, enqueue a job-queue entry, or `TaskScheduler.CreateTask`. Perform HTTP and large SQL after the session is running, in that background work. + +See sample: `oncompanyopen-subscribers-must-not-do-io.good.al`. + +## Anti Pattern + +An `OnAfterLogin` / `OnCompanyOpenCompleted` subscriber that calls `HttpClient` or scans a ledger. Detection signal: `HttpClient`, `FindSet`, or `CalcFields` inside a subscriber bound to those events. + +See sample: `oncompanyopen-subscribers-must-not-do-io.bad.al`. diff --git a/community/knowledge/performance/page-background-tasks-for-expensive-cues.bad.al b/community/knowledge/performance/page-background-tasks-for-expensive-cues.bad.al new file mode 100644 index 0000000..7f4d0e1 --- /dev/null +++ b/community/knowledge/performance/page-background-tasks-for-expensive-cues.bad.al @@ -0,0 +1,31 @@ +page 50100 "Cue Background Task Bad" +{ + PageType = CardPart; + ApplicationArea = All; + + layout + { + area(content) + { + cuegroup(Group) + { + field(OpenOrders; OpenOrderCount) + { + Caption = 'Open Sales Orders'; + } + } + } + } + + var + OpenOrderCount: Integer; + + trigger OnOpenPage() + var + SalesHeader: Record "Sales Header"; + begin + // Blocks Role Center render on an exact count of sales headers. + SalesHeader.SetRange("Document Type", SalesHeader."Document Type"::Order); + OpenOrderCount := SalesHeader.Count(); + end; +} diff --git a/community/knowledge/performance/page-background-tasks-for-expensive-cues.good.al b/community/knowledge/performance/page-background-tasks-for-expensive-cues.good.al new file mode 100644 index 0000000..2e14a47 --- /dev/null +++ b/community/knowledge/performance/page-background-tasks-for-expensive-cues.good.al @@ -0,0 +1,49 @@ +page 50100 "Cue Background Task Good" +{ + PageType = CardPart; + ApplicationArea = All; + + layout + { + area(content) + { + cuegroup(Group) + { + field(OpenOrders; OpenOrderCount) + { + Caption = 'Open Sales Orders'; + } + } + } + } + + var + OpenOrderCount: Integer; + TaskId: Integer; + + trigger OnAfterGetCurrRecord() + var + Args: Dictionary of [Text, Text]; + begin + CurrPage.EnqueueBackgroundTask(TaskId, Codeunit::"Cue Open Order Count", Args); + end; + + trigger OnPageBackgroundTaskCompleted(CompletedTaskId: Integer; Results: Dictionary of [Text, Text]) + begin + if Results.ContainsKey('Count') then + Evaluate(OpenOrderCount, Results.Get('Count')); + end; +} + +codeunit 50100 "Cue Open Order Count" +{ + trigger OnRun() + var + SalesHeader: Record "Sales Header"; + Results: Dictionary of [Text, Text]; + begin + SalesHeader.SetRange("Document Type", SalesHeader."Document Type"::Order); + Results.Add('Count', Format(SalesHeader.CountApprox())); + Page.SetBackgroundTaskResult(Results); + end; +} diff --git a/community/knowledge/performance/page-background-tasks-for-expensive-cues.md b/community/knowledge/performance/page-background-tasks-for-expensive-cues.md new file mode 100644 index 0000000..48fae1c --- /dev/null +++ b/community/knowledge/performance/page-background-tasks-for-expensive-cues.md @@ -0,0 +1,28 @@ +--- +bc-version: [15..] +domain: performance +keywords: [page-background-task, cue, rolecenter, enqueuebackgroundtask, ui-thread] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# Calculate expensive cues on a page background task + +> Contributions welcome — open a PR to refine or extend this article. + +## Description + +Role-center cues and CardPart totals that run `CalcFields`, scans, or HTTP on the UI thread freeze the shell until they finish. Page background tasks exist to return the page immediately and fill the number later. Enqueue mechanics, cancellation, and the read-only child session are covered in `microsoft/knowledge/ui/page-background-tasks.md`. This file is the performance trigger: a cue whose value is not needed to *open* the page must not run on the render path. + +## Best Practice + +Bind the cue to a page variable, enqueue a read-only calculation from `OnAfterGetCurrRecord` (not `OnAfterGetRecord` on a list), and apply the result in `OnPageBackgroundTaskCompleted`. Show a placeholder until then. + +See sample: `page-background-tasks-for-expensive-cues.good.al`. + +## Anti Pattern + +`CalcFields` or a ledger `Count` in `OnOpenPage` / `OnAfterGetCurrRecord` of a CueGroup CardPart with no background task. The Role Center waits on SQL the user may never look at. + +See sample: `page-background-tasks-for-expensive-cues.bad.al`. diff --git a/community/knowledge/performance/pass-var-record-to-preserve-partial-load-enumerator.bad.al b/community/knowledge/performance/pass-var-record-to-preserve-partial-load-enumerator.bad.al new file mode 100644 index 0000000..f08e8ad --- /dev/null +++ b/community/knowledge/performance/pass-var-record-to-preserve-partial-load-enumerator.bad.al @@ -0,0 +1,20 @@ +codeunit 50100 "Pass Var Enumerator Bad" +{ + procedure ListUsCustomerCities() + var + Customer: Record Customer; + begin + Customer.SetLoadFields(Name); + Customer.SetRange("Country/Region Code", 'US'); + if Customer.FindSet() then + repeat + // By-value copy: JIT on City does not update the enumerator. + Message(Customer.Name + ' ' + CityOf(Customer)); + until Customer.Next() = 0; + end; + + local procedure CityOf(Customer: Record Customer): Text + begin + exit(Customer.City); + end; +} diff --git a/community/knowledge/performance/pass-var-record-to-preserve-partial-load-enumerator.good.al b/community/knowledge/performance/pass-var-record-to-preserve-partial-load-enumerator.good.al new file mode 100644 index 0000000..9cff6e6 --- /dev/null +++ b/community/knowledge/performance/pass-var-record-to-preserve-partial-load-enumerator.good.al @@ -0,0 +1,21 @@ +codeunit 50100 "Pass Var Enumerator Good" +{ + procedure ListUsCustomerCities() + var + Customer: Record Customer; + begin + Customer.SetLoadFields(Name); + Customer.SetRange("Country/Region Code", 'US'); + if Customer.FindSet() then + repeat + EnsureCityLoaded(Customer); + Message(Customer.Name + ' ' + Customer.City); + until Customer.Next() = 0; + end; + + local procedure EnsureCityLoaded(var Customer: Record Customer) + begin + if not Customer.AreFieldsLoaded(Customer.City) then + Customer.LoadFields(Customer.City); + end; +} diff --git a/community/knowledge/performance/pass-var-record-to-preserve-partial-load-enumerator.md b/community/knowledge/performance/pass-var-record-to-preserve-partial-load-enumerator.md new file mode 100644 index 0000000..ce4c4bc --- /dev/null +++ b/community/knowledge/performance/pass-var-record-to-preserve-partial-load-enumerator.md @@ -0,0 +1,28 @@ +--- +bc-version: [all] +domain: performance +keywords: [setloadfields, jit-load, enumerator, var-parameter, pass-by-value, next] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# Pass the iterated record var so a JIT load updates the enumerator + +> Contributions welcome — open a PR to refine or extend this article. + +## Description + +A `FindSet`/`Next` loop builds an enumerator from the fields selected for load. Accessing an unloaded field triggers a JIT load. When the record is passed **by value**, the copy does not share that enumerator: the JIT loads the copy and leaves the enumerator unchanged, so **every later `Next()` JIT-loads again**. Passing `var` lets the first JIT update the enumerator. `AddLoadFields` on the original record before a by-value call is the other fix. This is independent of whether `SetLoadFields` was ordered before filters. + +## Best Practice + +Helpers that read extra fields on an in-flight iterator must take the record as `var`, or the caller must `AddLoadFields` those fields before the loop. Prefer declaring the extra fields up front so no JIT is needed. + +See sample: `pass-var-record-to-preserve-partial-load-enumerator.good.al`. + +## Anti Pattern + +A `SetLoadFields` loop that passes the iterator by value into a helper which then reads a field that was not loaded. The first row pays one JIT; every subsequent row pays it again because the enumerator never learned the extra field. + +See sample: `pass-var-record-to-preserve-partial-load-enumerator.bad.al`. diff --git a/community/knowledge/performance/prefer-related-table-over-extension-on-hot-ledgers.bad.al b/community/knowledge/performance/prefer-related-table-over-extension-on-hot-ledgers.bad.al new file mode 100644 index 0000000..0eb462c --- /dev/null +++ b/community/knowledge/performance/prefer-related-table-over-extension-on-hot-ledgers.bad.al @@ -0,0 +1,9 @@ +tableextension 50100 "G/L Entry Extra Ext" extends "G/L Entry" +{ + fields + { + // Stored companion columns are joined on every G/L Entry read. + field(50100; "External Reference"; Text[50]) { } + field(50101; "Integration Payload"; Blob) { } + } +} diff --git a/community/knowledge/performance/prefer-related-table-over-extension-on-hot-ledgers.good.al b/community/knowledge/performance/prefer-related-table-over-extension-on-hot-ledgers.good.al new file mode 100644 index 0000000..8699db0 --- /dev/null +++ b/community/knowledge/performance/prefer-related-table-over-extension-on-hot-ledgers.good.al @@ -0,0 +1,19 @@ +table 50100 "G/L Entry Extra" +{ + Caption = 'G/L Entry Extra'; + DataClassification = CustomerContent; + + fields + { + field(1; "Entry No."; Integer) + { + TableRelation = "G/L Entry"."Entry No."; + } + field(2; "External Reference"; Text[50]) { } + } + + keys + { + key(PK; "Entry No.") { Clustered = true; } + } +} diff --git a/community/knowledge/performance/prefer-related-table-over-extension-on-hot-ledgers.md b/community/knowledge/performance/prefer-related-table-over-extension-on-hot-ledgers.md new file mode 100644 index 0000000..c0e986c --- /dev/null +++ b/community/knowledge/performance/prefer-related-table-over-extension-on-hot-ledgers.md @@ -0,0 +1,28 @@ +--- +bc-version: [all] +domain: performance +keywords: [tableextension, companion-table, gl-entry, related-table, flowfield, hot-table] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# Prefer a related table over stored fields on hot ledgers + +> Contributions welcome — open a PR to refine or extend this article. + +## Description + +Stored fields on a table extension live in companion storage that is joined when the base row is read. On hot tables — G/L Entry, Item Ledger Entry, Cust. Ledger Entry — that join is paid on posting, lists, and APIs even when the extra columns are unused. A related table keyed by the ledger `Entry No.`, optionally surfaced with a FlowField or FactBox, leaves the base read path alone. Agents extend G/L Entry because it is "where the posting already is". + +## Best Practice + +Put optional, sparse, or integration attributes in a related table with the ledger entry number as primary key. Show them from a FactBox or a FlowField. Use a tableextension stored field only when the value must appear as a native list column and is read on almost every access. + +See sample: `prefer-related-table-over-extension-on-hot-ledgers.good.al`. + +## Anti Pattern + +`tableextension` on `"G/L Entry"` (or another posting table) that adds several stored `Text`/`Blob` fields used only by one integration. Every base-table read now joins those columns. + +See sample: `prefer-related-table-over-extension-on-hot-ledgers.bad.al`. diff --git a/community/knowledge/performance/query-results-bypass-primary-key-cache.bad.al b/community/knowledge/performance/query-results-bypass-primary-key-cache.bad.al new file mode 100644 index 0000000..9de4b03 --- /dev/null +++ b/community/knowledge/performance/query-results-bypass-primary-key-cache.bad.al @@ -0,0 +1,28 @@ +query 50100 "Query Bypass PK Cache Bad Q" +{ + QueryType = Normal; + + elements + { + dataitem(Customer; Customer) + { + filter(NoFilter; "No.") { } + column(Name; Name) { } + } + } +} + +codeunit 50100 "Query Bypass PK Cache Bad" +{ + procedure CustomerName(CustomerNo: Code[20]): Text + var + CustomerByNo: Query "Query Bypass PK Cache Bad Q"; + begin + // Query Open/Read never hits the server PK cache. + CustomerByNo.SetRange(NoFilter, CustomerNo); + CustomerByNo.Open(); + if CustomerByNo.Read() then + exit(CustomerByNo.Name); + CustomerByNo.Close(); + end; +} diff --git a/community/knowledge/performance/query-results-bypass-primary-key-cache.good.al b/community/knowledge/performance/query-results-bypass-primary-key-cache.good.al new file mode 100644 index 0000000..f128f58 --- /dev/null +++ b/community/knowledge/performance/query-results-bypass-primary-key-cache.good.al @@ -0,0 +1,12 @@ +codeunit 50100 "Query Bypass PK Cache Good" +{ + procedure CustomerName(CustomerNo: Code[20]): Text + var + Customer: Record Customer; + begin + // Repeated Get of the same No. is served from the transaction PK cache. + Customer.SetLoadFields(Name); + if Customer.Get(CustomerNo) then + exit(Customer.Name); + end; +} diff --git a/community/knowledge/performance/query-results-bypass-primary-key-cache.md b/community/knowledge/performance/query-results-bypass-primary-key-cache.md new file mode 100644 index 0000000..1f3205f --- /dev/null +++ b/community/knowledge/performance/query-results-bypass-primary-key-cache.md @@ -0,0 +1,28 @@ +--- +bc-version: [all] +domain: performance +keywords: [query, primary-key-cache, get, false-positive, n-plus-one, record-cache] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# Query results bypass the primary-key cache + +> Contributions welcome — open a PR to refine or extend this article. + +## 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. + +## Best Practice + +Keep `Record.Get` for repeated lookups of the same primary keys in one transaction. Use a Query when the work is a true join or aggregation that the record API would express as nested scans. Do not flag a guarded `Get` on a repeating key as an N+1 solely because a Query could express the same columns. + +See sample: `query-results-bypass-primary-key-cache.good.al`. + +## Anti Pattern + +Rewriting a helper that `Get`s Customer by `No.` on every sales line into a Query opened inside that helper. Distinct line customers still need a lookup; repeating customers were already served from the PK cache. The Query pays SQL every time. + +See sample: `query-results-bypass-primary-key-cache.bad.al`. diff --git a/community/knowledge/performance/reset-clears-partial-record-selection.bad.al b/community/knowledge/performance/reset-clears-partial-record-selection.bad.al new file mode 100644 index 0000000..3b1f999 --- /dev/null +++ b/community/knowledge/performance/reset-clears-partial-record-selection.bad.al @@ -0,0 +1,16 @@ +codeunit 50100 "Reset Clears LoadFields Bad" +{ + procedure ListUsCustomerNames() + var + Customer: Record Customer; + begin + Customer.SetLoadFields(Name); + // Reset restores a full-row load; the SetLoadFields above is discarded. + Customer.Reset(); + Customer.SetRange("Country/Region Code", 'US'); + if Customer.FindSet() then + repeat + Message(Customer.Name); + until Customer.Next() = 0; + end; +} diff --git a/community/knowledge/performance/reset-clears-partial-record-selection.good.al b/community/knowledge/performance/reset-clears-partial-record-selection.good.al new file mode 100644 index 0000000..bb6ee0d --- /dev/null +++ b/community/knowledge/performance/reset-clears-partial-record-selection.good.al @@ -0,0 +1,15 @@ +codeunit 50100 "Reset Clears LoadFields Good" +{ + procedure ListUsCustomerNames() + var + Customer: Record Customer; + begin + Customer.Reset(); + Customer.SetLoadFields(Name); + Customer.SetRange("Country/Region Code", 'US'); + if Customer.FindSet() then + repeat + Message(Customer.Name); + until Customer.Next() = 0; + end; +} diff --git a/community/knowledge/performance/reset-clears-partial-record-selection.md b/community/knowledge/performance/reset-clears-partial-record-selection.md new file mode 100644 index 0000000..c99f8d2 --- /dev/null +++ b/community/knowledge/performance/reset-clears-partial-record-selection.md @@ -0,0 +1,28 @@ +--- +bc-version: [all] +domain: performance +keywords: [reset, setloadfields, partial-record, load-selection, findset] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# Reset and empty SetLoadFields restore a full-row load + +> Contributions welcome — open a PR to refine or extend this article. + +## Description + +`SetLoadFields(...)` sticks to the record variable until something clears it. `Reset()` "changes fields select for loading back to all", and `SetLoadFields()` with no arguments does the same. A later `FindSet` or `Get` then materializes every normal field. Agents often place `SetLoadFields` first, then `Reset` to apply new filters, and assume the partial selection survives. It does not. + +## Best Practice + +Call `Reset` (or empty `SetLoadFields()`) first when the variable must be reused, then call `SetLoadFields` with the fields the next read actually uses, then apply filters and read. After `Reset`, a new `SetLoadFields` is required; the previous list is gone. + +See sample: `reset-clears-partial-record-selection.good.al`. + +## Anti Pattern + +`SetLoadFields(...)` followed by `Reset()` (or by parameterless `SetLoadFields()`) and then `FindSet` without restoring the load list. The filters look correct; the SQL still selects every column. + +See sample: `reset-clears-partial-record-selection.bad.al`. diff --git a/community/knowledge/performance/skip-setloadfields-on-write-and-transferfields.bad.al b/community/knowledge/performance/skip-setloadfields-on-write-and-transferfields.bad.al new file mode 100644 index 0000000..f5bfc45 --- /dev/null +++ b/community/knowledge/performance/skip-setloadfields-on-write-and-transferfields.bad.al @@ -0,0 +1,16 @@ +codeunit 50100 "Skip LoadFields Write Bad" +{ + procedure UppercaseUsCustomerNames() + var + Customer: Record Customer; + begin + // Partial load plus Modify forces a JIT full-row load per iteration. + Customer.SetLoadFields(Name); + Customer.SetRange("Country/Region Code", 'US'); + if Customer.FindSet(true) then + repeat + Customer.Name := UpperCase(Customer.Name); + Customer.Modify(false); + until Customer.Next() = 0; + end; +} diff --git a/community/knowledge/performance/skip-setloadfields-on-write-and-transferfields.good.al b/community/knowledge/performance/skip-setloadfields-on-write-and-transferfields.good.al new file mode 100644 index 0000000..2be561d --- /dev/null +++ b/community/knowledge/performance/skip-setloadfields-on-write-and-transferfields.good.al @@ -0,0 +1,15 @@ +codeunit 50100 "Skip LoadFields Write Good" +{ + procedure UppercaseUsCustomerNames() + var + Customer: Record Customer; + begin + // Write path: load the full row. SetLoadFields would JIT on Modify. + Customer.SetRange("Country/Region Code", 'US'); + if Customer.FindSet(true) then + repeat + Customer.Name := UpperCase(Customer.Name); + Customer.Modify(false); + until Customer.Next() = 0; + end; +} diff --git a/community/knowledge/performance/skip-setloadfields-on-write-and-transferfields.md b/community/knowledge/performance/skip-setloadfields-on-write-and-transferfields.md new file mode 100644 index 0000000..6010719 --- /dev/null +++ b/community/knowledge/performance/skip-setloadfields-on-write-and-transferfields.md @@ -0,0 +1,28 @@ +--- +bc-version: [all] +domain: performance +keywords: [setloadfields, partial-record, jit-load, modify, insert, transferfields, write-path] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# Skip SetLoadFields on write and copy paths + +> Contributions welcome — open a PR to refine or extend this article. + +## Description + +`SetLoadFields` is a read optimization. `Insert`, `Modify`, `Delete`, `Rename`, `TransferFields`, and copy into a temporary record all require a fully loaded row. When those operations run on a partial record, the platform issues a just-in-time load of the missing fields. That extra round-trip costs more than loading the full row on the original `FindSet` or `Get`. Agents that apply `use-setloadfields-for-partial-records.md` to every loop therefore make write loops slower, not faster. + +## Best Practice + +Use `SetLoadFields` only when the subsequent access is read-only. On a loop that writes the iterated record, or copies it with `TransferFields` / `Copy` onto a temporary record, omit `SetLoadFields` so the initial read already materializes every field those operations need. + +See sample: `skip-setloadfields-on-write-and-transferfields.good.al`. + +## Anti Pattern + +Calling `SetLoadFields` immediately before a `FindSet` whose body `Modify`s, `Delete`s, `Rename`s, or `TransferFields`s the same record. The review signal is partial-record setup on a record variable that is written or copied in the same iteration, not the mere presence of `SetLoadFields` on a read-only loop. + +See sample: `skip-setloadfields-on-write-and-transferfields.bad.al`. diff --git a/community/knowledge/performance/use-dedicated-lookup-pages-not-full-lists.bad.al b/community/knowledge/performance/use-dedicated-lookup-pages-not-full-lists.bad.al new file mode 100644 index 0000000..589576d --- /dev/null +++ b/community/knowledge/performance/use-dedicated-lookup-pages-not-full-lists.bad.al @@ -0,0 +1,43 @@ +table 50100 "Campaign Member" +{ + Caption = 'Campaign Member'; + // Full list as lookup runs FactBoxes and extra columns on every dropdown. + LookupPageId = Page::"Campaign Member List"; + DrillDownPageId = Page::"Campaign Member List"; + DataClassification = CustomerContent; + + fields + { + field(1; "No."; Code[20]) { } + field(2; Name; Text[100]) { } + field(3; "Balance (LCY)"; Decimal) { } + } + + keys + { + key(PK; "No.") { Clustered = true; } + } +} + +page 50100 "Campaign Member List" +{ + PageType = List; + SourceTable = "Campaign Member"; + + layout + { + area(content) + { + repeater(Rows) + { + field("No."; Rec."No.") { } + field(Name; Rec.Name) { } + field("Balance (LCY)"; Rec."Balance (LCY)") { } + } + } + area(factboxes) + { + part(Details; "Campaign Member List") { } + } + } +} diff --git a/community/knowledge/performance/use-dedicated-lookup-pages-not-full-lists.good.al b/community/knowledge/performance/use-dedicated-lookup-pages-not-full-lists.good.al new file mode 100644 index 0000000..db69089 --- /dev/null +++ b/community/knowledge/performance/use-dedicated-lookup-pages-not-full-lists.good.al @@ -0,0 +1,57 @@ +table 50100 "Campaign Member" +{ + Caption = 'Campaign Member'; + LookupPageId = Page::"Campaign Member Lookup"; + DrillDownPageId = Page::"Campaign Member List"; + DataClassification = CustomerContent; + + fields + { + field(1; "No."; Code[20]) { } + field(2; Name; Text[100]) { } + field(3; "Balance (LCY)"; Decimal) { } + } + + keys + { + key(PK; "No.") { Clustered = true; } + } +} + +page 50100 "Campaign Member Lookup" +{ + PageType = List; + SourceTable = "Campaign Member"; + Caption = 'Campaign Members'; + + layout + { + area(content) + { + repeater(Rows) + { + field("No."; Rec."No.") { } + field(Name; Rec.Name) { } + } + } + } +} + +page 50101 "Campaign Member List" +{ + PageType = List; + SourceTable = "Campaign Member"; + + layout + { + area(content) + { + repeater(Rows) + { + field("No."; Rec."No.") { } + field(Name; Rec.Name) { } + field("Balance (LCY)"; Rec."Balance (LCY)") { } + } + } + } +} diff --git a/community/knowledge/performance/use-dedicated-lookup-pages-not-full-lists.md b/community/knowledge/performance/use-dedicated-lookup-pages-not-full-lists.md new file mode 100644 index 0000000..191568e --- /dev/null +++ b/community/knowledge/performance/use-dedicated-lookup-pages-not-full-lists.md @@ -0,0 +1,28 @@ +--- +bc-version: [all] +domain: performance +keywords: [lookuppageid, lookup-page, list-page, factbox, table-relation, dropdown] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# Point lookups at a dedicated lookup page, not the full list + +> Contributions welcome — open a PR to refine or extend this article. + +## Description + +A `TableRelation` lookup opens the table's `LookupPageId`. If that is the full list page, the lookup runs that page's triggers, FactBoxes, and calculated fields even though the dropdown never shows them. The base application added dedicated Customer, Vendor, and Item lookup pages for this reason. Agents set `LookupPageId` to the main list because it already exists. + +## Best Practice + +Give master tables a slim lookup page (`PageType = List`, few columns, no FactBoxes, no heavy `OnAfterGetRecord`) and assign it to `LookupPageId`. Keep the full list for `DrillDownPageId` and the role-explorer entry. + +See sample: `use-dedicated-lookup-pages-not-full-lists.good.al`. + +## Anti Pattern + +`LookupPageId = Page::"... List"` on a table that already has (or should have) a lookup page. Opening a field lookup then pays list-page cost. The signal is `LookupPageId` pointing at a page that declares FactBoxes or a wide repeater. + +See sample: `use-dedicated-lookup-pages-not-full-lists.bad.al`. diff --git a/community/knowledge/performance/validate-on-partial-record-forces-jit.bad.al b/community/knowledge/performance/validate-on-partial-record-forces-jit.bad.al new file mode 100644 index 0000000..248d1a1 --- /dev/null +++ b/community/knowledge/performance/validate-on-partial-record-forces-jit.bad.al @@ -0,0 +1,16 @@ +codeunit 50100 "Validate Partial Rec Bad" +{ + procedure UppercaseUsCustomerNames() + var + Customer: Record Customer; + begin + Customer.SetLoadFields(Name); + Customer.SetRange("Country/Region Code", 'US'); + if Customer.FindSet(true) then + repeat + // Validate touches other fields and TableRelation reads; JIT undoes the partial load. + Customer.Validate(Name, UpperCase(Customer.Name)); + Customer.Modify(false); + until Customer.Next() = 0; + end; +} diff --git a/community/knowledge/performance/validate-on-partial-record-forces-jit.good.al b/community/knowledge/performance/validate-on-partial-record-forces-jit.good.al new file mode 100644 index 0000000..df1e307 --- /dev/null +++ b/community/knowledge/performance/validate-on-partial-record-forces-jit.good.al @@ -0,0 +1,14 @@ +codeunit 50100 "Validate Partial Rec Good" +{ + procedure UppercaseUsCustomerNames() + var + Customer: Record Customer; + begin + Customer.SetRange("Country/Region Code", 'US'); + if Customer.FindSet(true) then + repeat + Customer.Name := UpperCase(Customer.Name); + Customer.Modify(false); + until Customer.Next() = 0; + end; +} diff --git a/community/knowledge/performance/validate-on-partial-record-forces-jit.md b/community/knowledge/performance/validate-on-partial-record-forces-jit.md new file mode 100644 index 0000000..00b9657 --- /dev/null +++ b/community/knowledge/performance/validate-on-partial-record-forces-jit.md @@ -0,0 +1,28 @@ +--- +bc-version: [all] +domain: performance +keywords: [validate, setloadfields, jit-load, table-relation, onvalidate, partial-record] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# Validate on a partial record forces JIT loads + +> Contributions welcome — open a PR to refine or extend this article. + +## Description + +`Validate` runs the field's `OnValidate` trigger and TableRelation lookups. Those code paths routinely touch other fields on the same record. On a partial row those extra fields are not loaded, so the platform JIT-loads them — often the rest of the row — plus any related-table reads the trigger performs. Distinct from `skip-setloadfields-on-write-and-transferfields.md`: the write may be `Modify(false)`; `Validate` is what blows the partial load. Agents that combine `SetLoadFields` with `Validate` in a loop produce slower code than an unoptimized assignment. + +## Best Practice + +In a partial-record loop, assign fields directly when trigger side effects are not required. If `Validate` is required, do not use `SetLoadFields` on that iterator, or `AddLoadFields` every field the validate path can touch before the read. + +See sample: `validate-on-partial-record-forces-jit.good.al`. + +## Anti Pattern + +`SetLoadFields` on a handful of columns, then `Validate` inside the loop. The load list looks optimal; runtime JIT and TableRelation I/O dominate. The signal is `Validate(` on a record that still has a `SetLoadFields` in the same procedure. + +See sample: `validate-on-partial-record-forces-jit.bad.al`. diff --git a/community/knowledge/performance/visible-false-does-not-skip-page-field-cost.bad.al b/community/knowledge/performance/visible-false-does-not-skip-page-field-cost.bad.al new file mode 100644 index 0000000..0ec934f --- /dev/null +++ b/community/knowledge/performance/visible-false-does-not-skip-page-field-cost.bad.al @@ -0,0 +1,23 @@ +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; + } + } + } + } +} diff --git a/community/knowledge/performance/visible-false-does-not-skip-page-field-cost.good.al b/community/knowledge/performance/visible-false-does-not-skip-page-field-cost.good.al new file mode 100644 index 0000000..da6f87f --- /dev/null +++ b/community/knowledge/performance/visible-false-does-not-skip-page-field-cost.good.al @@ -0,0 +1,18 @@ +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) { } + } + } + } +} diff --git a/community/knowledge/performance/visible-false-does-not-skip-page-field-cost.md b/community/knowledge/performance/visible-false-does-not-skip-page-field-cost.md new file mode 100644 index 0000000..fc6281d --- /dev/null +++ b/community/knowledge/performance/visible-false-does-not-skip-page-field-cost.md @@ -0,0 +1,28 @@ +--- +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`. From 5f9328618d9daae481abeff30c1ca74f1754e01e Mon Sep 17 00:00:00 2001 From: Stefano Demiliani Date: Fri, 21 Aug 2026 14:24:36 +0200 Subject: [PATCH 2/3] fix(community/performance): address PR #134 review comments Fixes technical inaccuracies and behavioral issues raised during review of the community performance knowledge articles. avoid-currpage-update-in-onaftergetrecord.md - Removed OnAfterGetCurrRecord from the Best Practice section. That trigger is itself implicitly re-entered on every refresh, so placing CurrPage.Update(false) inside it can re-trigger the very loop the rule warns about. Only OnAction remains as the recommended location. batch-number-series-instead-of-getnextno-per-row.md - Scoped the lock/contention claim to gapless (Normal) series only. Added explicit statement that Allow Gaps series use NumberSequence sequences and do not hold the series-line lock, so the anti-pattern detection signal now excludes Allow Gaps series. countapprox-for-progress-not-count.bad.al / .good.al - Changed FindSet(true) to FindSet() in both samples. The loop is read-only; UpdLock is not needed and was misleading. countapprox-for-progress-not-count.md - Qualified the Count() cost claim: it is expensive only when no SIFT key covers all filtered fields (forcing a SELECT COUNT(*)); a filtered count with matching SIFT coverage is cheap. Added a parenthetical noting that SIFT coverage cannot be assumed for arbitrary filters. dataaccessintent-readonly-on-analytical-objects.md - Changed bc-version from [all] to ["16.."]. DataAccessIntent was introduced at runtime 5.0 / BC 16 and has no effect in earlier versions. - Added precision to the supported objects: pages must be PageType=API with Editable=false; for queries, replica routing only applies when the query is exposed via OData/API, not for AL-to-AL calls. httpclient-inside-write-transaction-holds-locks.good.al - Replaced the Commit()-before-HttpClient pattern with a two-codeunit task-deferral pattern. The write completes inside the caller's transaction (locks released naturally when it ends); a TaskScheduler task runs the HTTP call in a separate session where no write- transaction lock is held. httpclient-inside-write-transaction-holds-locks.md - Changed Best Practice to recommend TaskScheduler/job queue deferral as the primary remedy. - Added an explicit warning against Commit() as a generic remedy: it irrevocably commits all prior writes in the current transaction, so a subsequent failure cannot roll them back. Commit() is appropriate only at top-level entry points where partial persistence is intentional. oncompanyopen-subscribers-must-not-do-io.good.al - Added a ClientType guard so the subscriber exits immediately in background task sessions (OnAfterLogin fires there too, which would create an unbounded task chain without the guard). - Added a TaskScheduler.TaskExists idempotency check to avoid queuing duplicate tasks on repeated logins. - Fixed the error-fallback codeunit in CreateTask from a self-reference to 0 (no error codeunit). - Added a 60-second delay (CurrentDateTime() + 60000) so the task does not compete with the login session itself. prefer-related-table-over-extension-on-hot-ledgers.md - Changed bc-version from [all] to ["23.."]. The companion-table join optimisation (single join per base table, automatic exclusion on List/ OData pages with partial records) was introduced in v23. - Scoped the "join is always paid" claim: since v23 the join is excluded on List/ListPart/OData pages when no extension field is loaded under partial-record semantics, but it is still paid on every posting path and any AL code that accesses an extension field. skip-setloadfields-on-write-and-transferfields.bad.al / .good.al / .md - Changed the example scenario from Modify(false) (which is actually valid with a partial record) to TransferFields+Insert into a temporary record, which is a documented full-load operation. - Removed Modify from the list of operations that force a full load. - Added an explicit note in the .md that Modify itself is not in the full-load list; SetLoadFields is safe to use before Modify(false). use-dedicated-lookup-pages-not-full-lists.bad.al - Added a separate CardPart page definition (50101) to replace the self-referencing FactBox part that referenced the same list page it was embedded in. A part cannot refer to its own container page. validate-on-partial-record-forces-jit.good.al - Restored SetLoadFields to the good sample so it exercises a partial record and the contrast with .bad.al is field selection, not the absence of the feature. The good sample loads both Name and Search Name (the field Name.OnValidate writes), while the bad sample loads only Name, causing a JIT reload of Search Name on every Validate call. --- ...oid-currpage-update-in-onaftergetrecord.md | 2 +- ...ber-series-instead-of-getnextno-per-row.md | 4 ++-- .../countapprox-for-progress-not-count.bad.al | 2 +- ...countapprox-for-progress-not-count.good.al | 2 +- .../countapprox-for-progress-not-count.md | 4 ++-- ...ssintent-readonly-on-analytical-objects.md | 6 +++--- ...side-write-transaction-holds-locks.good.al | 21 +++++++++++++++---- ...nt-inside-write-transaction-holds-locks.md | 2 +- ...anyopen-subscribers-must-not-do-io.good.al | 12 +++++++++-- ...ted-table-over-extension-on-hot-ledgers.md | 9 ++++---- ...dfields-on-write-and-transferfields.bad.al | 14 ++++++------- ...fields-on-write-and-transferfields.good.al | 12 +++++------ ...tloadfields-on-write-and-transferfields.md | 6 +++--- ...dicated-lookup-pages-not-full-lists.bad.al | 19 ++++++++++++++++- ...idate-on-partial-record-forces-jit.good.al | 4 +++- 15 files changed, 80 insertions(+), 39 deletions(-) diff --git a/community/knowledge/performance/avoid-currpage-update-in-onaftergetrecord.md b/community/knowledge/performance/avoid-currpage-update-in-onaftergetrecord.md index 4aafb22..20e6968 100644 --- a/community/knowledge/performance/avoid-currpage-update-in-onaftergetrecord.md +++ b/community/knowledge/performance/avoid-currpage-update-in-onaftergetrecord.md @@ -17,7 +17,7 @@ application-area: [all] ## 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. +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` once, not per row. See sample: `avoid-currpage-update-in-onaftergetrecord.good.al`. diff --git a/community/knowledge/performance/batch-number-series-instead-of-getnextno-per-row.md b/community/knowledge/performance/batch-number-series-instead-of-getnextno-per-row.md index 9d8bfe6..616c789 100644 --- a/community/knowledge/performance/batch-number-series-instead-of-getnextno-per-row.md +++ b/community/knowledge/performance/batch-number-series-instead-of-getnextno-per-row.md @@ -13,7 +13,7 @@ application-area: [all] ## 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. +`Codeunit "No. Series".GetNextNo` on a **gapless (Normal)** series 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. Series configured with **Allow Gaps** instead obtain numbers through `NumberSequence` and do not hold the series-line lock between calls, so they are not affected by this pattern. Codeunit `"No. Series - Batch"` issues gapless numbers in memory and writes the series line once via `SaveState`. ## Best Practice @@ -23,6 +23,6 @@ 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. +`NoSeries.GetNextNo(...)` inside `repeat ... Insert ... until Next() = 0` where the series is **gapless** (Allow Gaps = false). Each iteration takes the series-line lock. The signal is `"No. Series"` (not `"No. Series - Batch"`) in a loop that inserts more than one row; do not flag the same pattern when the series has Allow Gaps enabled, as the `NumberSequence` path already avoids the lock. See sample: `batch-number-series-instead-of-getnextno-per-row.bad.al`. diff --git a/community/knowledge/performance/countapprox-for-progress-not-count.bad.al b/community/knowledge/performance/countapprox-for-progress-not-count.bad.al index 83e029f..46dee20 100644 --- a/community/knowledge/performance/countapprox-for-progress-not-count.bad.al +++ b/community/knowledge/performance/countapprox-for-progress-not-count.bad.al @@ -11,7 +11,7 @@ codeunit 50100 "CountApprox Progress Bad" // 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 + if Customer.FindSet() then repeat Counter += 1; Window.Update(1, Counter); diff --git a/community/knowledge/performance/countapprox-for-progress-not-count.good.al b/community/knowledge/performance/countapprox-for-progress-not-count.good.al index d4c5f61..ed2f22d 100644 --- a/community/knowledge/performance/countapprox-for-progress-not-count.good.al +++ b/community/knowledge/performance/countapprox-for-progress-not-count.good.al @@ -10,7 +10,7 @@ codeunit 50100 "CountApprox Progress Good" Customer.SetRange("Country/Region Code", 'US'); Total := Customer.CountApprox(); Window.Open('Processing #1###### of #2######'); - if Customer.FindSet(true) then + if Customer.FindSet() then repeat Counter += 1; Window.Update(1, Counter); diff --git a/community/knowledge/performance/countapprox-for-progress-not-count.md b/community/knowledge/performance/countapprox-for-progress-not-count.md index d2b9a6f..4261658 100644 --- a/community/knowledge/performance/countapprox-for-progress-not-count.md +++ b/community/knowledge/performance/countapprox-for-progress-not-count.md @@ -13,7 +13,7 @@ application-area: [all] ## 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". +`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 @@ -23,6 +23,6 @@ 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. +`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`. diff --git a/community/knowledge/performance/dataaccessintent-readonly-on-analytical-objects.md b/community/knowledge/performance/dataaccessintent-readonly-on-analytical-objects.md index 9a245f5..89ee55e 100644 --- a/community/knowledge/performance/dataaccessintent-readonly-on-analytical-objects.md +++ b/community/knowledge/performance/dataaccessintent-readonly-on-analytical-objects.md @@ -1,5 +1,5 @@ --- -bc-version: [all] +bc-version: ["16.."] domain: performance keywords: [dataaccessintent, read-only, read-scale-out, report, api-page, query] technologies: [al] @@ -13,11 +13,11 @@ application-area: [all] ## 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`. +`DataAccessIntent` was introduced at runtime 5.0 (BC 16) and has no effect in earlier versions. Reports, API pages (`PageType = API` with `Editable = false`), and queries that only read can run against a read replica when `DataAccessIntent = ReadOnly`. For queries, replica routing only applies when the query is exposed via OData/API; running a query in AL code is unaffected. Without the property these objects 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. +On report objects and `PageType = API` pages with `Editable = false` that never write, set `DataAccessIntent = ReadOnly`. For query objects, set it when the query is consumed via OData or an API endpoint. 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`. diff --git a/community/knowledge/performance/httpclient-inside-write-transaction-holds-locks.good.al b/community/knowledge/performance/httpclient-inside-write-transaction-holds-locks.good.al index 6136722..218f22c 100644 --- a/community/knowledge/performance/httpclient-inside-write-transaction-holds-locks.good.al +++ b/community/knowledge/performance/httpclient-inside-write-transaction-holds-locks.good.al @@ -1,13 +1,26 @@ 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) + begin + Customer."Search Name" := Customer.Name; + Customer.Modify(false); + TaskScheduler.CreateTask(Codeunit::"Customer Sync Task", 0, true, CompanyName(), CurrentDateTime()); + end; +} + +codeunit 50101 "Customer Sync Task" +{ + trigger OnRun() var Client: HttpClient; + Customer: Record Customer; Response: HttpResponseMessage; begin - Customer."Search Name" := Customer.Name; - Customer.Modify(false); - Commit(); - Client.Get(StrSubstNo('https://example.local/sync/%1', Customer."No."), Response); + // Separate session: no write-transaction lock is held during the HTTP call. + if Customer.FindSet() then + repeat + Client.Get(StrSubstNo('https://example.local/sync/%1', Customer."No."), Response); + until Customer.Next() = 0; end; } diff --git a/community/knowledge/performance/httpclient-inside-write-transaction-holds-locks.md b/community/knowledge/performance/httpclient-inside-write-transaction-holds-locks.md index 35ed210..c7ad656 100644 --- a/community/knowledge/performance/httpclient-inside-write-transaction-holds-locks.md +++ b/community/knowledge/performance/httpclient-inside-write-transaction-holds-locks.md @@ -17,7 +17,7 @@ The first database write opens an AL write transaction that the runtime holds un ## Best Practice -Finish database writes and `Commit()` (or return from the write execution) before `HttpClient.Send`/`Get`/`Post`. If the call can be slow or retry, isolate it in a job queue or `TaskScheduler` task so UI and other sessions are not sitting on the writer's locks. +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. See sample: `httpclient-inside-write-transaction-holds-locks.good.al`. diff --git a/community/knowledge/performance/oncompanyopen-subscribers-must-not-do-io.good.al b/community/knowledge/performance/oncompanyopen-subscribers-must-not-do-io.good.al index d23ca87..8938140 100644 --- a/community/knowledge/performance/oncompanyopen-subscribers-must-not-do-io.good.al +++ b/community/knowledge/performance/oncompanyopen-subscribers-must-not-do-io.good.al @@ -3,8 +3,16 @@ codeunit 50100 "Login Subscriber IO Good" [EventSubscriber(ObjectType::Codeunit, Codeunit::"System Initialization", OnAfterLogin, '', false, false)] local procedure OnAfterLogin() begin - // Defer HTTP and heavy SQL to a job; session open must return immediately. - TaskScheduler.CreateTask(Codeunit::"Login Subscriber IO Work", Codeunit::"Login Subscriber IO Work", true, CompanyName(), CurrentDateTime()); + // Guard to interactive sessions only; background task sessions also raise OnAfterLogin. + if not (Session.GetCurrentClientType() in [ClientType::Web, ClientType::Windows, ClientType::Desktop, ClientType::Tablet, ClientType::Phone]) then + exit; + + // Idempotent: skip if a task for this codeunit is already queued. + if TaskScheduler.TaskExists(Codeunit::"Login Subscriber IO Work") then + exit; + + // Defer the I/O work; CreateTask is the only write allowed on this path. + TaskScheduler.CreateTask(Codeunit::"Login Subscriber IO Work", 0, true, CompanyName(), CurrentDateTime() + 60000); end; } diff --git a/community/knowledge/performance/prefer-related-table-over-extension-on-hot-ledgers.md b/community/knowledge/performance/prefer-related-table-over-extension-on-hot-ledgers.md index c0e986c..dd03115 100644 --- a/community/knowledge/performance/prefer-related-table-over-extension-on-hot-ledgers.md +++ b/community/knowledge/performance/prefer-related-table-over-extension-on-hot-ledgers.md @@ -1,5 +1,5 @@ --- -bc-version: [all] +bc-version: ["23.."] domain: performance keywords: [tableextension, companion-table, gl-entry, related-table, flowfield, hot-table] technologies: [al] @@ -13,16 +13,17 @@ application-area: [all] ## Description -Stored fields on a table extension live in companion storage that is joined when the base row is read. On hot tables — G/L Entry, Item Ledger Entry, Cust. Ledger Entry — that join is paid on posting, lists, and APIs even when the extra columns are unused. A related table keyed by the ledger `Entry No.`, optionally surfaced with a FlowField or FactBox, leaves the base read path alone. Agents extend G/L Entry because it is "where the posting already is". +Since v23, all extensions on the same base table share at most one companion-table join, and the platform automatically excludes that join on List, ListPart, and OData pages when partial records are in effect and no extension field is loaded. However, the join is still paid on every posting path and any AL code that accesses an extension field — or that runs without partial-record semantics. On hot tables — G/L Entry, Item Ledger Entry, Cust. Ledger Entry — even a single access per posted row adds up at volume. A related table keyed by the ledger `Entry No.`, optionally surfaced with a FlowField or FactBox, leaves the base read path entirely untouched. Agents extend G/L Entry because it is "where the posting already is". ## Best Practice -Put optional, sparse, or integration attributes in a related table with the ledger entry number as primary key. Show them from a FactBox or a FlowField. Use a tableextension stored field only when the value must appear as a native list column and is read on almost every access. +Put optional, sparse, or integration attributes in a related table with the ledger entry number as primary key. Show them from a FactBox or a FlowField. +Use a tableextension stored field only when the value must appear as a native list column and is read on almost every access. See sample: `prefer-related-table-over-extension-on-hot-ledgers.good.al`. ## Anti Pattern -`tableextension` on `"G/L Entry"` (or another posting table) that adds several stored `Text`/`Blob` fields used only by one integration. Every base-table read now joins those columns. +`tableextension` on `"G/L Entry"` (or another posting table) that adds several stored `Text`/`Blob` fields used only by one integration. The companion join is paid on every posting and on any AL code path that loads extension fields, even when those columns are not needed for the current operation. See sample: `prefer-related-table-over-extension-on-hot-ledgers.bad.al`. diff --git a/community/knowledge/performance/skip-setloadfields-on-write-and-transferfields.bad.al b/community/knowledge/performance/skip-setloadfields-on-write-and-transferfields.bad.al index f5bfc45..aa5a1ca 100644 --- a/community/knowledge/performance/skip-setloadfields-on-write-and-transferfields.bad.al +++ b/community/knowledge/performance/skip-setloadfields-on-write-and-transferfields.bad.al @@ -1,16 +1,16 @@ codeunit 50100 "Skip LoadFields Write Bad" { - procedure UppercaseUsCustomerNames() + procedure CopyActiveCustomers(var TempCustomer: Record Customer temporary) var Customer: Record Customer; begin - // Partial load plus Modify forces a JIT full-row load per iteration. - Customer.SetLoadFields(Name); - Customer.SetRange("Country/Region Code", 'US'); - if Customer.FindSet(true) then + // TransferFields requires all fields; partial load forces JIT per row. + Customer.SetLoadFields("No.", Name); + Customer.SetRange(Blocked, Customer.Blocked::" "); + if Customer.FindSet() then repeat - Customer.Name := UpperCase(Customer.Name); - Customer.Modify(false); + TempCustomer.TransferFields(Customer); + TempCustomer.Insert(); until Customer.Next() = 0; end; } diff --git a/community/knowledge/performance/skip-setloadfields-on-write-and-transferfields.good.al b/community/knowledge/performance/skip-setloadfields-on-write-and-transferfields.good.al index 2be561d..8c6e7f8 100644 --- a/community/knowledge/performance/skip-setloadfields-on-write-and-transferfields.good.al +++ b/community/knowledge/performance/skip-setloadfields-on-write-and-transferfields.good.al @@ -1,15 +1,15 @@ codeunit 50100 "Skip LoadFields Write Good" { - procedure UppercaseUsCustomerNames() + procedure CopyActiveCustomers(var TempCustomer: Record Customer temporary) var Customer: Record Customer; begin - // Write path: load the full row. SetLoadFields would JIT on Modify. - Customer.SetRange("Country/Region Code", 'US'); - if Customer.FindSet(true) then + // TransferFields needs all fields; omit SetLoadFields so the initial read loads the full row. + Customer.SetRange(Blocked, Customer.Blocked::" "); + if Customer.FindSet() then repeat - Customer.Name := UpperCase(Customer.Name); - Customer.Modify(false); + TempCustomer.TransferFields(Customer); + TempCustomer.Insert(); until Customer.Next() = 0; end; } diff --git a/community/knowledge/performance/skip-setloadfields-on-write-and-transferfields.md b/community/knowledge/performance/skip-setloadfields-on-write-and-transferfields.md index 6010719..8077f28 100644 --- a/community/knowledge/performance/skip-setloadfields-on-write-and-transferfields.md +++ b/community/knowledge/performance/skip-setloadfields-on-write-and-transferfields.md @@ -13,16 +13,16 @@ application-area: [all] ## Description -`SetLoadFields` is a read optimization. `Insert`, `Modify`, `Delete`, `Rename`, `TransferFields`, and copy into a temporary record all require a fully loaded row. When those operations run on a partial record, the platform issues a just-in-time load of the missing fields. That extra round-trip costs more than loading the full row on the original `FindSet` or `Get`. Agents that apply `use-setloadfields-for-partial-records.md` to every loop therefore make write loops slower, not faster. +`SetLoadFields` is a read optimization. The platform's [partial-record usage guidelines](https://learn.microsoft.com/en-us/dynamics365/business-central/dev-itpro/developer/devenv-partial-records#usage-guidelines) list the operations that require every field to already be present: `Insert`, `Delete`, `Rename`, `TransferFields`, and copying a record into a temporary table. When those operations run on a partial record, the platform issues a just-in-time load of the missing fields. That extra round-trip costs more than loading the full row on the original `FindSet` or `Get`. Note: `Modify` itself is **not** in this list — a `Modify(false)` that only touches loaded fields is safe with a partial record. ## Best Practice -Use `SetLoadFields` only when the subsequent access is read-only. On a loop that writes the iterated record, or copies it with `TransferFields` / `Copy` onto a temporary record, omit `SetLoadFields` so the initial read already materializes every field those operations need. +Omit `SetLoadFields` on loops whose body performs a documented full-load operation (`Insert`, `Delete`, `Rename`, `TransferFields`, or assignment into a temporary record) on the same record variable, so the initial read already materializes every field those operations need. See sample: `skip-setloadfields-on-write-and-transferfields.good.al`. ## Anti Pattern -Calling `SetLoadFields` immediately before a `FindSet` whose body `Modify`s, `Delete`s, `Rename`s, or `TransferFields`s the same record. The review signal is partial-record setup on a record variable that is written or copied in the same iteration, not the mere presence of `SetLoadFields` on a read-only loop. +Calling `SetLoadFields` immediately before a `FindSet` whose body performs `Delete`, `Rename`, `TransferFields`, or copies the record into a temporary table. The review signal is a partial-record setup on a record variable that feeds one of these documented full-load operations in the same iteration. See sample: `skip-setloadfields-on-write-and-transferfields.bad.al`. diff --git a/community/knowledge/performance/use-dedicated-lookup-pages-not-full-lists.bad.al b/community/knowledge/performance/use-dedicated-lookup-pages-not-full-lists.bad.al index 589576d..4cb842d 100644 --- a/community/knowledge/performance/use-dedicated-lookup-pages-not-full-lists.bad.al +++ b/community/knowledge/performance/use-dedicated-lookup-pages-not-full-lists.bad.al @@ -37,7 +37,24 @@ page 50100 "Campaign Member List" } area(factboxes) { - part(Details; "Campaign Member List") { } + // Full list carries this FactBox on every dropdown open — expensive. + part(Details; "Campaign Member Details FB") { } + } + } +} + +page 50101 "Campaign Member Details FB" +{ + PageType = CardPart; + SourceTable = "Campaign Member"; + + layout + { + area(content) + { + field("No."; Rec."No.") { } + field(Name; Rec.Name) { } + field("Balance (LCY)"; Rec."Balance (LCY)") { } } } } diff --git a/community/knowledge/performance/validate-on-partial-record-forces-jit.good.al b/community/knowledge/performance/validate-on-partial-record-forces-jit.good.al index df1e307..7e85c18 100644 --- a/community/knowledge/performance/validate-on-partial-record-forces-jit.good.al +++ b/community/knowledge/performance/validate-on-partial-record-forces-jit.good.al @@ -4,10 +4,12 @@ codeunit 50100 "Validate Partial Rec Good" var Customer: Record Customer; begin + // Include every field that Name.OnValidate reads so the runtime never JIT-loads. + Customer.SetLoadFields(Name, "Search Name"); Customer.SetRange("Country/Region Code", 'US'); if Customer.FindSet(true) then repeat - Customer.Name := UpperCase(Customer.Name); + Customer.Validate(Name, UpperCase(Customer.Name)); Customer.Modify(false); until Customer.Next() = 0; end; From 8373bc47172747b0e3891f4291fb413cfed3f69d Mon Sep 17 00:00:00 2001 From: Stefano Demiliani Date: Fri, 21 Aug 2026 14:42:47 +0200 Subject: [PATCH 3/3] fix(community/performance): correct httpclient and oncompanyopen good samples httpclient-inside-write-transaction-holds-locks.good.al - Pass Customer.RecordId as the last argument to CreateTask so the task is bound to the single customer that was written, not to all customers. - Declare TableNo = Customer on the task codeunit so the platform loads the bound record into Rec automatically when OnRun executes. - Replace the FindSet loop over all customers with Rec."No.", preserving the one-customer scope of the original SyncCustomerLastName procedure. oncompanyopen-subscribers-must-not-do-io.good.al - Replace Session.GetCurrentClientType() with Session.CurrentClientType(), the correct platform method name. - Fix the idempotency check: TaskScheduler.TaskExists() requires a Guid, not a codeunit integer ID. Store the Guid returned by CreateTask in IsolatedStorage (DataScope::Company) under a fixed key; on the next login read it back as Text, Evaluate it to Guid, and pass that Guid to TaskExists so the type matches the method signature. --- ...inside-write-transaction-holds-locks.good.al | 13 ++++++------- ...mpanyopen-subscribers-must-not-do-io.good.al | 17 +++++++++++------ 2 files changed, 17 insertions(+), 13 deletions(-) diff --git a/community/knowledge/performance/httpclient-inside-write-transaction-holds-locks.good.al b/community/knowledge/performance/httpclient-inside-write-transaction-holds-locks.good.al index 218f22c..ea490fe 100644 --- a/community/knowledge/performance/httpclient-inside-write-transaction-holds-locks.good.al +++ b/community/knowledge/performance/httpclient-inside-write-transaction-holds-locks.good.al @@ -5,22 +5,21 @@ codeunit 50100 "HttpClient Holds Locks Good" begin Customer."Search Name" := Customer.Name; Customer.Modify(false); - TaskScheduler.CreateTask(Codeunit::"Customer Sync Task", 0, true, CompanyName(), CurrentDateTime()); + // 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); end; } codeunit 50101 "Customer Sync Task" { + TableNo = Customer; + trigger OnRun() var Client: HttpClient; - Customer: Record Customer; Response: HttpResponseMessage; begin - // Separate session: no write-transaction lock is held during the HTTP call. - if Customer.FindSet() then - repeat - Client.Get(StrSubstNo('https://example.local/sync/%1', Customer."No."), Response); - until Customer.Next() = 0; + // 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); end; } diff --git a/community/knowledge/performance/oncompanyopen-subscribers-must-not-do-io.good.al b/community/knowledge/performance/oncompanyopen-subscribers-must-not-do-io.good.al index 8938140..d1b3aa5 100644 --- a/community/knowledge/performance/oncompanyopen-subscribers-must-not-do-io.good.al +++ b/community/knowledge/performance/oncompanyopen-subscribers-must-not-do-io.good.al @@ -2,17 +2,22 @@ codeunit 50100 "Login Subscriber IO Good" { [EventSubscriber(ObjectType::Codeunit, Codeunit::"System Initialization", OnAfterLogin, '', false, false)] local procedure OnAfterLogin() + var + TaskId: Guid; + StoredId: Text; begin // Guard to interactive sessions only; background task sessions also raise OnAfterLogin. - if not (Session.GetCurrentClientType() in [ClientType::Web, ClientType::Windows, ClientType::Desktop, ClientType::Tablet, ClientType::Phone]) then + if not (Session.CurrentClientType() in [ClientType::Web, ClientType::Windows, ClientType::Desktop, ClientType::Tablet, ClientType::Phone]) then exit; - // Idempotent: skip if a task for this codeunit is already queued. - if TaskScheduler.TaskExists(Codeunit::"Login Subscriber IO Work") then - exit; + // Idempotent: TaskExists requires the GUID returned by CreateTask, stored across logins. + if IsolatedStorage.Get('LoginSyncTaskId', DataScope::Company, StoredId) then + if Evaluate(TaskId, StoredId) then + if TaskScheduler.TaskExists(TaskId) then + exit; - // Defer the I/O work; CreateTask is the only write allowed on this path. - TaskScheduler.CreateTask(Codeunit::"Login Subscriber IO Work", 0, true, CompanyName(), CurrentDateTime() + 60000); + TaskId := TaskScheduler.CreateTask(Codeunit::"Login Subscriber IO Work", 0, true, CompanyName(), CurrentDateTime() + 60000); + IsolatedStorage.Set('LoginSyncTaskId', Format(TaskId), DataScope::Company); end; }