diff --git a/microsoft/knowledge/performance/blob-fields-are-not-cached-prefer-media.md b/microsoft/knowledge/performance/blob-fields-are-not-cached-prefer-media.md new file mode 100644 index 0000000..75b4347 --- /dev/null +++ b/microsoft/knowledge/performance/blob-fields-are-not-cached-prefer-media.md @@ -0,0 +1,26 @@ +--- +bc-version: [all] +domain: performance +keywords: [blob, media, mediaset, cache, image, thumbnail] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# Blob fields are never cached — prefer Media or MediaSet for images + +## Description + +`Blob` field contents are not cached by the Business Central server or the client. Every read re-fetches the full payload from the database, even when the same blob was read moments earlier in the same session. For images displayed on a page, this turns into a database round-trip per render. + +`Media` and `MediaSet` are purpose-built for this and behave differently in two ways that matter for performance. First, they are cached on the client, so subsequent renders of the same image do not re-hit the database. Second, the platform generates a thumbnail when the data is saved, so a list or card page can show the thumbnail immediately and lazy-load the full-resolution image — typically via a Page Background Task — only when needed. + +`Blob` remains appropriate for non-image binary data that is written once and rarely read, or for data the platform does not need to render. For any field that is displayed repeatedly — profile pictures, item images, logos on documents — `Media` or `MediaSet` is the default. + +## Best Practice + +Store images in `Media` or `MediaSet` fields. Bind the thumbnail to the page; load full-resolution data asynchronously when the user opens the full view. Reserve `Blob` for opaque payloads that are not rendered in the UI. + +## Anti Pattern + +An Item Image field defined as `Blob` and shown directly on a list page. Every scroll re-fetches every image from SQL, the list page load time scales with row count and image size, and no client-side caching mitigates the cost. diff --git a/microsoft/knowledge/performance/hidden-flowfields-still-calculate-on-pages.md b/microsoft/knowledge/performance/hidden-flowfields-still-calculate-on-pages.md new file mode 100644 index 0000000..184e363 --- /dev/null +++ b/microsoft/knowledge/performance/hidden-flowfields-still-calculate-on-pages.md @@ -0,0 +1,24 @@ +--- +bc-version: [all] +domain: performance +keywords: [flowfield, visible, enabled, page, calcfields, feature-management] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# Hidden FlowFields still calculate on pages + +## Description + +Setting `Visible = false` or `Enabled = false` on a FlowField hides the control but does not suppress the calculation. The server still runs the underlying CalcFields for every row the page renders. On a list page over a large table, the invisible column keeps consuming the same SQL as a visible one — the hiding is cosmetic only, and a diff that "turns off" an expensive FlowField by flipping `Visible` fixes nothing on the server. + +There are two correct remedies. The durable one is to remove the FlowField from the page or page-extension definition entirely — property toggles are not enough. The environment-level one, available where supported, is the **Calculate only visible FlowFields** feature in Feature Management; when enabled, the AL runtime skips calculation for non-visible FlowFields on pages. The feature is opt-in and administrator-controlled, so code cannot assume it is active. + +## Best Practice + +Remove unused or hidden FlowFields from the page or page extension. If the field is needed for some users but expensive for others, factor into a dedicated page variant rather than hiding it in place. Do not rely on `Visible = false` as a performance fix unless the tenant has enabled the Calculate only visible FlowFields feature and that assumption is acceptable. + +## Anti Pattern + +A performance PR that sets `Visible = false` on an expensive FlowField on a list page and claims the column no longer impacts load time. The control disappears from the UI, the CalcFields still runs for every row, and the list page stays slow. diff --git a/microsoft/knowledge/performance/keep-oncompanyopen-subscribers-lightweight.md b/microsoft/knowledge/performance/keep-oncompanyopen-subscribers-lightweight.md new file mode 100644 index 0000000..16a2061 --- /dev/null +++ b/microsoft/knowledge/performance/keep-oncompanyopen-subscribers-lightweight.md @@ -0,0 +1,26 @@ +--- +bc-version: [all] +domain: performance +keywords: [oncompanyopen, oncompanyopencompleted, session, sign-in, subscriber, startup] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# Keep OnCompanyOpen and OnCompanyOpenCompleted subscribers lightweight + +## Description + +`OnCompanyOpen` and `OnCompanyOpenCompleted` are raised every time a session is created — not only for interactive sign-ins, but also for every web service call, every job queue entry, every scheduled task, and every page background task. The session cannot run any AL code until every subscriber on these events has finished. Interactive users see a spinner; web service callers see elevated response times; background sessions sit idle waiting to start. + +Anything expensive in these subscribers is paid per session across the whole tenant. The two patterns that typically cause production incidents are outgoing HTTP calls to external services — which block AL execution until they complete (or time out) — and long-running SQL over large tables. An external service that is slow or unreachable turns into a tenant-wide sign-in outage, not a degraded feature. + +The code often looks harmless in review: a telemetry ping, a configuration refresh, a "just make sure the setup record exists" Get-or-Insert. Multiplied by session creations per minute, each of these becomes the critical path of sign-in. + +## Best Practice + +Keep `OnCompanyOpen` and `OnCompanyOpenCompleted` subscribers short and in-memory. Defer work that touches external services or large tables to a Page Background Task, a job queue entry, or a lazy first-use path. If an outgoing HTTP call in startup is truly unavoidable, set an aggressive timeout so a failing endpoint cannot stall session creation. + +## Anti Pattern + +An `OnCompanyOpen` subscriber that calls an external licensing API over HttpClient without a tight timeout. When the endpoint is slow, every new session in the tenant — UI, API, background — waits on the HTTP call before it can run any AL. diff --git a/microsoft/knowledge/performance/locktable-applies-to-whole-table-in-transaction.md b/microsoft/knowledge/performance/locktable-applies-to-whole-table-in-transaction.md new file mode 100644 index 0000000..d6bf687 --- /dev/null +++ b/microsoft/knowledge/performance/locktable-applies-to-whole-table-in-transaction.md @@ -0,0 +1,24 @@ +--- +bc-version: [all] +domain: performance +keywords: [locktable, updlock, transaction, contention, scope] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# LockTable applies to the whole table for the rest of the transaction + +## Description + +`Record.LockTable` is commonly read as "lock this record variable", but it does not work that way. The call applies `WITH (UPDLOCK)` to every subsequent read against the underlying table in the current transaction, regardless of which record variable issues the read. If `ItemA.LockTable` runs, then an unrelated `ItemB` variable on `Item`, a `FindSet` from a helper codeunit on `Item`, and any nested code that reads `Item` all acquire UPDLOCK until the transaction commits. + +The consequence is that calling LockTable early in a transaction — for example at the top of a routine "to be safe" — upgrades every read of that table for the remainder of the transaction to a writer-blocking lock. Contention scales with transaction length, not with how many writes the code actually performs. A LockTable deep in a call graph can silently serialize readers that never touch the LockTable-ing variable. + +## Best Practice + +Defer `LockTable` as late as possible and place it as close to the actual modification as you can. Keep transactions short so the UPDLOCK window is narrow. Do not add LockTable preemptively to "protect" a read that is not part of a read-modify-write sequence — the correct tool for read consistency is an isolation level (see Record.ReadIsolation), not a write lock. + +## Anti Pattern + +A procedure that calls `Rec.LockTable()` at the start "before doing anything" and then performs a long read-heavy validation before the eventual Modify. Every read in the validation now takes UPDLOCK on the whole table, and every other session that tries to read the same table waits on this transaction. diff --git a/microsoft/knowledge/performance/query-objects-bypass-primary-key-cache.md b/microsoft/knowledge/performance/query-objects-bypass-primary-key-cache.md new file mode 100644 index 0000000..3c05629 --- /dev/null +++ b/microsoft/knowledge/performance/query-objects-bypass-primary-key-cache.md @@ -0,0 +1,26 @@ +--- +bc-version: [all] +domain: performance +keywords: [query, cache, primary-key-cache, record-api, sql] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# Query objects bypass the primary-key cache and always hit SQL + +## Description + +The Record API reuses a server-side primary-key cache: repeated reads of the same rows within a session or request can be served from memory without going to SQL. Query objects do not participate in that cache. Every execution of a query goes to the database, even when the same rows were just read through a Record variable in the same transaction. + +This inverts the usual intuition that queries are always faster than record loops. Queries win when they exploit a covering index, aggregate, or join multiple tables in SQL that AL would otherwise loop. They lose when the data is small, already cached, or read repeatedly in a short window — the per-call SQL round-trip dominates. + +Query objects also cannot write, cannot be backed by a page, and do not see the records a temp-table-backed AL flow has inserted but not committed. Choose them for set-based reads over indexed data, not as a generic replacement for the Record API. + +## Best Practice + +Use a query object when the shape of the work is genuinely set-based: aggregation, multi-table join, or a large read that benefits from a covering index. For hot single-record or small-result reads — especially lookups that will repeat in the same request — prefer the Record API so the primary-key cache does its job. + +## Anti Pattern + +Replacing a `Get` or a short filtered `FindSet` inside a frequently-called helper with a query object "for performance". Every caller now pays a SQL round-trip that the Record API cache had been absorbing, and the helper gets slower under load, not faster. diff --git a/microsoft/knowledge/performance/table-event-subscribers-disable-bulk-modifyall-and-deleteall.md b/microsoft/knowledge/performance/table-event-subscribers-disable-bulk-modifyall-and-deleteall.md new file mode 100644 index 0000000..1674838 --- /dev/null +++ b/microsoft/knowledge/performance/table-event-subscribers-disable-bulk-modifyall-and-deleteall.md @@ -0,0 +1,24 @@ +--- +bc-version: [all] +domain: performance +keywords: [event, subscriber, modifyall, deleteall, bulk, row-by-row] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# Table event subscribers force ModifyAll and DeleteAll to run row-by-row + +## Description + +`ModifyAll` and `DeleteAll` normally compile to a single set-based SQL UPDATE or DELETE. That optimization is conditional: if any subscriber is bound to the table's modify or delete events — `OnBeforeModifyEvent`, `OnAfterModifyEvent`, `OnBeforeDeleteEvent`, `OnAfterDeleteEvent`, and their Rec counterparts — the server must invoke AL per affected row so the subscriber sees each record. The operation falls back to a row-by-row loop, one SQL statement per row, inside the same transaction. + +The slowdown is invisible in the caller's source: the call site still reads as a bulk operation. It only shows up under load, and adding an apparently cheap subscriber (even an empty one, or one that guards on a condition and returns) is enough to trigger the fallback for every caller of ModifyAll/DeleteAll on that table across the system. Central tables — Item Ledger Entry, G/L Entry, Sales Line — are the worst places to attach such subscribers because every extension's bulk operation pays the cost. + +## Best Practice + +Before subscribing to a table's modify or delete events, consider whether the logic can live elsewhere — on the triggering action, on a specific OnValidate, or on a business-event publisher. If the subscriber is unavoidable, scope it as narrowly as possible and document that it forces row-by-row execution so future maintainers understand the cost. Watch PRs that add such subscribers to heavily-modified tables. + +## Anti Pattern + +An empty or nearly-empty `OnAfterModifyEvent` subscriber on `Sales Line` added as a placeholder for future integration. Every `ModifyAll` on `Sales Line` — in the base app, in every extension, in every tenant — now runs one SQL UPDATE per row. diff --git a/microsoft/knowledge/performance/uninstall-test-framework-to-measure-insert-performance.md b/microsoft/knowledge/performance/uninstall-test-framework-to-measure-insert-performance.md new file mode 100644 index 0000000..ca4d81c --- /dev/null +++ b/microsoft/knowledge/performance/uninstall-test-framework-to-measure-insert-performance.md @@ -0,0 +1,26 @@ +--- +bc-version: [all] +domain: performance +keywords: [test-framework, bulk-insert, performance-test, benchmark, insert] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# Uninstall the test framework to measure insert performance + +## Description + +Business Central's server uses a bulk insert optimization that batches multiple row inserts into a single SQL round-trip when conditions allow. When the test framework is installed on the environment, that optimization is disabled — inserts fall back to one SQL statement per row. The behavior is a side-effect of how the test framework instruments AL execution and applies whether or not any test is actually running. + +For functional tests this is invisible; for performance measurement it is catastrophic. A benchmark that inserts ten thousand rows with the test framework present reports a number that has nothing to do with production, because production will not run in row-by-row mode. Treating the measurement as a real baseline produces conclusions that are wrong by a large constant factor. + +The same caveat applies to Update and Delete paths where bulk optimizations exist — the test framework's presence suppresses them. + +## Best Practice + +Before running any insert, update, or delete throughput benchmark — whether via the Performance Toolkit, a hand-rolled harness, or `SessionInformation` assertions — uninstall the test framework from the target environment. Re-install it only for functional test runs. Document this step in the benchmark procedure so future measurements are comparable. + +## Anti Pattern + +A performance regression report comparing two builds on a sandbox that has the test framework installed. Both numbers are row-by-row timings; the ratio between them may be meaningful, but neither number reflects production, and any absolute throughput claim derived from the run is wrong.