From 3b403aaef038dcee94a3fd0a9f41023aafb382a7 Mon Sep 17 00:00:00 2001 From: Kilian Seizinger <56249171+pri-kise@users.noreply.github.com> Date: Wed, 28 Oct 2026 11:43:09 +0100 Subject: [PATCH 1/2] knowledge(performance): add trigger-only fields to API pages with AddLoadFields API/OData pages load only fields bound to page controls. Fields that only trigger code reads (typically OnAfterGetRecord filling a variable-bound API field) are JIT-loaded per row. Recommend Rec.AddLoadFields in OnOpenPage, as Microsoft's ExpenseAgent API pages do, and flag SetLoadFields there because it replaces the platform's layout-based selection. --- ...or-trigger-only-fields-on-api-pages.bad.al | 36 ++++++++++++++++ ...r-trigger-only-fields-on-api-pages.good.al | 41 ++++++++++++++++++ ...ds-for-trigger-only-fields-on-api-pages.md | 42 +++++++++++++++++++ 3 files changed, 119 insertions(+) create mode 100644 microsoft/knowledge/performance/addloadfields-for-trigger-only-fields-on-api-pages.bad.al create mode 100644 microsoft/knowledge/performance/addloadfields-for-trigger-only-fields-on-api-pages.good.al create mode 100644 microsoft/knowledge/performance/addloadfields-for-trigger-only-fields-on-api-pages.md diff --git a/microsoft/knowledge/performance/addloadfields-for-trigger-only-fields-on-api-pages.bad.al b/microsoft/knowledge/performance/addloadfields-for-trigger-only-fields-on-api-pages.bad.al new file mode 100644 index 00000000..088658e5 --- /dev/null +++ b/microsoft/knowledge/performance/addloadfields-for-trigger-only-fields-on-api-pages.bad.al @@ -0,0 +1,36 @@ +page 50231 "Perf Sample API LoadFields Bad" +{ + PageType = API; + APIPublisher = 'contoso'; + APIGroup = 'sales'; + APIVersion = 'v1.0'; + EntityName = 'customer'; + EntitySetName = 'customers'; + ODataKeyFields = SystemId; + SourceTable = Customer; + Editable = false; + + layout + { + area(Content) + { + repeater(Records) + { + field(id; Rec.SystemId) { } + field(number; Rec."No.") { } + field(displayName; Rec.Name) { } + field(address; AddressText) { } + } + } + } + + trigger OnAfterGetRecord() + begin + // Post Code and City are not bound to any control, so the platform does not + // load them. Reading them here causes a just-in-time load for every row. + AddressText := Rec."Post Code" + ' ' + Rec.City; + end; + + var + AddressText: Text; +} diff --git a/microsoft/knowledge/performance/addloadfields-for-trigger-only-fields-on-api-pages.good.al b/microsoft/knowledge/performance/addloadfields-for-trigger-only-fields-on-api-pages.good.al new file mode 100644 index 00000000..d44c47de --- /dev/null +++ b/microsoft/knowledge/performance/addloadfields-for-trigger-only-fields-on-api-pages.good.al @@ -0,0 +1,41 @@ +page 50230 "Perf Sample API LoadFields Good" +{ + PageType = API; + APIPublisher = 'contoso'; + APIGroup = 'sales'; + APIVersion = 'v1.0'; + EntityName = 'customer'; + EntitySetName = 'customers'; + ODataKeyFields = SystemId; + SourceTable = Customer; + Editable = false; + + layout + { + area(Content) + { + repeater(Records) + { + field(id; Rec.SystemId) { } + field(number; Rec."No.") { } + field(displayName; Rec.Name) { } + field(address; AddressText) { } + } + } + } + + trigger OnOpenPage() + begin + // Bound fields are selected by the platform. Post Code and City are extra + // because only OnAfterGetRecord reads them. + Rec.AddLoadFields("Post Code", City); + end; + + trigger OnAfterGetRecord() + begin + AddressText := Rec."Post Code" + ' ' + Rec.City; + end; + + var + AddressText: Text; +} diff --git a/microsoft/knowledge/performance/addloadfields-for-trigger-only-fields-on-api-pages.md b/microsoft/knowledge/performance/addloadfields-for-trigger-only-fields-on-api-pages.md new file mode 100644 index 00000000..65c56d42 --- /dev/null +++ b/microsoft/knowledge/performance/addloadfields-for-trigger-only-fields-on-api-pages.md @@ -0,0 +1,42 @@ +--- +bc-version: [17..] +domain: performance +keywords: [api-page, odata, addloadfields, onopenpage, onaftergetrecord, partial-record, jit, global-variable] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# Add trigger-only fields to API pages with AddLoadFields in OnOpenPage + +## Description + +API and OData pages load only the fields bound to page controls, so a field read only in trigger code costs a JIT load per row. + +The platform applies partial records to pages opened through OData: the fields referenced in the page layout are loaded, plus any the platform needs for subpage linking. Fields that only trigger code reads are not selected. A common case is an API field bound to a global variable that `OnAfterGetRecord` fills from `Rec` (to convert a currency code, combine address fields, or look up a related id). The `Rec` fields it reads are not bound to any control, so each row's first access issues an implicit `Get`. On a collection request that is one extra database round trip per returned row. A JIT load can also fail with "Inconsistent read of field(s)" or "JIT loading of field(s) ... failed" when another session changes or deletes the row in between. + +The compiler and analyzers do not flag this, and the page returns correct data, so the cost only shows in telemetry and SQL load. + +## Best Practice + +In `OnOpenPage`, call `Rec.AddLoadFields(...)` with the normal fields that page triggers read but no control binds. `AddLoadFields` adds to the platform's layout-based selection, so all needed fields arrive with the first read. Microsoft's own API pages use this pattern; see References. Microsoft Learn also names `OnFindRecord` as a place to add the fields for OData pages. + +Fields bound directly to a control (`field(name; Rec."Field")`) need no entry. FlowFields are not covered by load fields; calculate them with `SetAutoCalcFields` or `CalcFields`. A page with `SourceTableTemporary = true` has no database cost to save. + +See sample: [`addloadfields-for-trigger-only-fields-on-api-pages.good.al`](addloadfields-for-trigger-only-fields-on-api-pages.good.al). + +## Anti Pattern + +An API page whose `OnAfterGetRecord` (or a procedure it calls) reads `Rec` fields that no control binds, with no matching `Rec.AddLoadFields` in `OnOpenPage`. Typical tell: global variables bound to API fields and assigned from `Rec."..."` in `OnAfterGetRecord`. + +Calling `Rec.SetLoadFields(...)` in `OnOpenPage` instead is also wrong: `SetLoadFields` replaces the selection, so layout fields that are not listed become JIT loads. + +See sample: [`addloadfields-for-trigger-only-fields-on-api-pages.bad.al`](addloadfields-for-trigger-only-fields-on-api-pages.bad.al). + +## References + +- [Using partial records](https://learn.microsoft.com/en-us/dynamics365/business-central/dev-itpro/developer/devenv-partial-records): sections "OData pages", "Just-in-time (JIT) loading" and "Preventing inconsistent read and JIT loading errors". +- [Record.AddLoadFields method](https://learn.microsoft.com/en-us/dynamics365/business-central/dev-itpro/developer/methods-auto/record/record-addloadfields-method). +- BCApps `Expense Rules API`, `OnOpenPage` adds `"Currency Code"` for the variable-bound `currencyCode` field: [ExpenseRulesAPI.Page.al](https://github.com/microsoft/BCApps/blob/90a3a39be4a51c82ca01ab262607af0ac559ecfe/src/Apps/W1/ExpenseAgent/app/src/APIs/ExpenseRulesAPI.Page.al#L95-L104). +- BCApps `Expenses API`, `OnOpenPage` adds four fields read in `OnAfterGetRecord`: [ExpensesAPI.Page.al](https://github.com/microsoft/BCApps/blob/90a3a39be4a51c82ca01ab262607af0ac559ecfe/src/Apps/W1/ExpenseAgent/app/src/APIs/ExpensesAPI.Page.al#L360-L376). +- Report counterpart: [`addloadfields-in-report-onpredataitem.md`](addloadfields-in-report-onpredataitem.md). From a2a18fd81f849c87924a7d46c94860a3488538d9 Mon Sep 17 00:00:00 2001 From: Kilian Seizinger <56249171+pri-kise@users.noreply.github.com> Date: Fri, 30 Oct 2026 16:34:45 +0100 Subject: [PATCH 2/2] Address review: effective load selection, fixture registration, runtime-6 samples - Exempt fields that are already selected (bound, primary key, SystemId, audit and filtered fields) and accept OnFindRecord as well as OnOpenPage. - Flag SetLoadFields only when its projection drops a needed field; a fieldless call loads all normal fields. - Drop the fixed one-round-trip-per-row claim; JIT cost depends on enumerator updates and caching. - Register the article in the performance evaluation override. - Shorten sample page names to stay within 30 characters after Bad/Good to Eval neutralization and add DelayedInsert = true for runtime 6. --- evaluation/review-fixtures.json | 1 + ...or-trigger-only-fields-on-api-pages.bad.al | 3 ++- ...r-trigger-only-fields-on-api-pages.good.al | 3 ++- ...ds-for-trigger-only-fields-on-api-pages.md | 22 ++++++++++++------- 4 files changed, 19 insertions(+), 10 deletions(-) diff --git a/evaluation/review-fixtures.json b/evaluation/review-fixtures.json index 5fc0cfbb..4d919511 100644 --- a/evaluation/review-fixtures.json +++ b/evaluation/review-fixtures.json @@ -113,6 +113,7 @@ "use-setautocalcfields-for-per-row-flowfields", "temporary-tables-have-no-database-cost", "use-setloadfields-for-partial-records", + "addloadfields-for-trigger-only-fields-on-api-pages", "prefer-modifyall-over-per-row-modify", "al-methods-limited-during-write-transactions", "avoid-user-prompts-inside-transactions", diff --git a/microsoft/knowledge/performance/addloadfields-for-trigger-only-fields-on-api-pages.bad.al b/microsoft/knowledge/performance/addloadfields-for-trigger-only-fields-on-api-pages.bad.al index 088658e5..e7af52ac 100644 --- a/microsoft/knowledge/performance/addloadfields-for-trigger-only-fields-on-api-pages.bad.al +++ b/microsoft/knowledge/performance/addloadfields-for-trigger-only-fields-on-api-pages.bad.al @@ -1,4 +1,4 @@ -page 50231 "Perf Sample API LoadFields Bad" +page 50231 "Perf API LoadFields Bad" { PageType = API; APIPublisher = 'contoso'; @@ -9,6 +9,7 @@ page 50231 "Perf Sample API LoadFields Bad" ODataKeyFields = SystemId; SourceTable = Customer; Editable = false; + DelayedInsert = true; layout { diff --git a/microsoft/knowledge/performance/addloadfields-for-trigger-only-fields-on-api-pages.good.al b/microsoft/knowledge/performance/addloadfields-for-trigger-only-fields-on-api-pages.good.al index d44c47de..f7e58acb 100644 --- a/microsoft/knowledge/performance/addloadfields-for-trigger-only-fields-on-api-pages.good.al +++ b/microsoft/knowledge/performance/addloadfields-for-trigger-only-fields-on-api-pages.good.al @@ -1,4 +1,4 @@ -page 50230 "Perf Sample API LoadFields Good" +page 50230 "Perf API LoadFields Good" { PageType = API; APIPublisher = 'contoso'; @@ -9,6 +9,7 @@ page 50230 "Perf Sample API LoadFields Good" ODataKeyFields = SystemId; SourceTable = Customer; Editable = false; + DelayedInsert = true; layout { diff --git a/microsoft/knowledge/performance/addloadfields-for-trigger-only-fields-on-api-pages.md b/microsoft/knowledge/performance/addloadfields-for-trigger-only-fields-on-api-pages.md index 65c56d42..b346ec16 100644 --- a/microsoft/knowledge/performance/addloadfields-for-trigger-only-fields-on-api-pages.md +++ b/microsoft/knowledge/performance/addloadfields-for-trigger-only-fields-on-api-pages.md @@ -11,32 +11,38 @@ application-area: [all] ## Description -API and OData pages load only the fields bound to page controls, so a field read only in trigger code costs a JIT load per row. +API and OData pages load only the fields bound to page controls, so a field read only in trigger code triggers JIT loads. -The platform applies partial records to pages opened through OData: the fields referenced in the page layout are loaded, plus any the platform needs for subpage linking. Fields that only trigger code reads are not selected. A common case is an API field bound to a global variable that `OnAfterGetRecord` fills from `Rec` (to convert a currency code, combine address fields, or look up a related id). The `Rec` fields it reads are not bound to any control, so each row's first access issues an implicit `Get`. On a collection request that is one extra database round trip per returned row. A JIT load can also fail with "Inconsistent read of field(s)" or "JIT loading of field(s) ... failed" when another session changes or deletes the row in between. +The platform applies partial records to pages opened through OData: the fields referenced in the page layout are loaded, plus any the platform needs for subpage linking. Fields that only trigger code reads are not selected. A common case is an API field bound to a global variable that `OnAfterGetRecord` fills from `Rec` (to convert a currency code, combine address fields, or look up a related id). Reading such a field causes a just-in-time load, an implicit `Get` and an extra access to the data source. The platform then updates the enumerator, and server caching can serve the `Get`, so the number of extra SQL round trips is not fixed. Do not state a per-row count without telemetry or SQL evidence. A JIT load can also fail with "Inconsistent read of field(s)" or "JIT loading of field(s) ... failed" when another session changes or deletes the row in between. The compiler and analyzers do not flag this, and the page returns correct data, so the cost only shows in telemetry and SQL load. ## Best Practice -In `OnOpenPage`, call `Rec.AddLoadFields(...)` with the normal fields that page triggers read but no control binds. `AddLoadFields` adds to the platform's layout-based selection, so all needed fields arrive with the first read. Microsoft's own API pages use this pattern; see References. Microsoft Learn also names `OnFindRecord` as a place to add the fields for OData pages. +Before iteration starts, add the normal fields that page triggers read but that are not otherwise selected. Use `Rec.AddLoadFields(...)` in `OnOpenPage`, as Microsoft's own API pages do (see References); Microsoft Learn also names `OnFindRecord` for OData pages. `AddLoadFields` adds to the platform's layout-based selection, so all needed fields arrive with the first read. -Fields bound directly to a control (`field(name; Rec."Field")`) need no entry. FlowFields are not covered by load fields; calculate them with `SetAutoCalcFields` or `CalcFields`. A page with `SourceTableTemporary = true` has no database cost to save. +No entry is needed for a field that is already selected: + +- a field bound directly to a control (`field(name; Rec."Field")`); +- primary-key fields, `SystemId` and the audit fields (`SystemCreatedAt`, `SystemCreatedBy`, `SystemModifiedAt`, `SystemModifiedBy`), which are always loaded; +- fields that the page filters on, which are always loaded. + +FlowFields are not covered by load fields; calculate them with `SetAutoCalcFields` or `CalcFields`. A page with `SourceTableTemporary = true` has no database cost to save. See sample: [`addloadfields-for-trigger-only-fields-on-api-pages.good.al`](addloadfields-for-trigger-only-fields-on-api-pages.good.al). ## Anti Pattern -An API page whose `OnAfterGetRecord` (or a procedure it calls) reads `Rec` fields that no control binds, with no matching `Rec.AddLoadFields` in `OnOpenPage`. Typical tell: global variables bound to API fields and assigned from `Rec."..."` in `OnAfterGetRecord`. +An API page whose `OnAfterGetRecord`, or a procedure it calls, reads a normal `Rec` field that no part of the effective selection covers. That means no bound control, not always loaded (primary key, `SystemId`, audit field, filtered field), and no `AddLoadFields` for it in `OnOpenPage` or `OnFindRecord`. Typical tell: global variables bound to API fields and assigned from such `Rec` fields in `OnAfterGetRecord`. -Calling `Rec.SetLoadFields(...)` in `OnOpenPage` instead is also wrong: `SetLoadFields` replaces the selection, so layout fields that are not listed become JIT loads. +`Rec.SetLoadFields(...)` with a field list in those triggers replaces the platform's selection. Flag it only when the resulting projection drops a field that the layout or the triggers read. `SetLoadFields()` without fields loads all readable normal fields, so it causes no JIT load (but gives up the partial-record benefit). See sample: [`addloadfields-for-trigger-only-fields-on-api-pages.bad.al`](addloadfields-for-trigger-only-fields-on-api-pages.bad.al). ## References -- [Using partial records](https://learn.microsoft.com/en-us/dynamics365/business-central/dev-itpro/developer/devenv-partial-records): sections "OData pages", "Just-in-time (JIT) loading" and "Preventing inconsistent read and JIT loading errors". -- [Record.AddLoadFields method](https://learn.microsoft.com/en-us/dynamics365/business-central/dev-itpro/developer/methods-auto/record/record-addloadfields-method). +- [Using partial records](https://learn.microsoft.com/en-us/dynamics365/business-central/dev-itpro/developer/devenv-partial-records): sections "OData pages", "Just-in-time (JIT) loading", "Iterating over records" and "Preventing inconsistent read and JIT loading errors". +- [Record.AddLoadFields method](https://learn.microsoft.com/en-us/dynamics365/business-central/dev-itpro/developer/methods-auto/record/record-addloadfields-method): lists the fields that are always loaded. [Record.SetLoadFields method](https://learn.microsoft.com/en-us/dynamics365/business-central/dev-itpro/developer/methods-auto/record/record-setloadfields-method): explains calling it without fields. - BCApps `Expense Rules API`, `OnOpenPage` adds `"Currency Code"` for the variable-bound `currencyCode` field: [ExpenseRulesAPI.Page.al](https://github.com/microsoft/BCApps/blob/90a3a39be4a51c82ca01ab262607af0ac559ecfe/src/Apps/W1/ExpenseAgent/app/src/APIs/ExpenseRulesAPI.Page.al#L95-L104). - BCApps `Expenses API`, `OnOpenPage` adds four fields read in `OnAfterGetRecord`: [ExpensesAPI.Page.al](https://github.com/microsoft/BCApps/blob/90a3a39be4a51c82ca01ab262607af0ac559ecfe/src/Apps/W1/ExpenseAgent/app/src/APIs/ExpensesAPI.Page.al#L360-L376). - Report counterpart: [`addloadfields-in-report-onpredataitem.md`](addloadfields-in-report-onpredataitem.md).