knowledge(upgrade): upgrade code must not use ChangeCompany (#210)

* knowledge(upgrade): upgrade code must not use ChangeCompany

Addresses ADO bug 651092. Upgrade and feature data update code must run in the
context of the company being upgraded; ChangeCompany leaves triggers, events and
upgrade tags in the calling company and races the target company's own upgrade.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>

* Address review: self-contained samples, fixture registration, scoped feature routing

- Declare the sample table in both companions so each compiles on its own.
- Register no-changecompany-in-upgrade and changecompany-runs-triggers-in-the-calling-company in review-fixtures.json.
- Limit the Feature Data Update rule to UpdateData/AfterUpdate and their reachable helpers; read-only IsDataUpdateRequired/ReviewData preflight is permitted and shown as a clean control.
- Add feature data update execution to al-upgrade-review applicability and not-applicable scope.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>

* Clarify cross-company upgrade sequencing without race claims

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>

---------

Co-authored-by: Jesper Schulz-Wedde <jesper.schulzwedde@microsoft.com>
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
Jesper Schulz-Wedde 2026-10-02 11:35:11 +02:00 • committed by GitHub
parent 206c5feff4
commit 488ce50775
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 186 additions and 8 deletions

View file

@ -13,7 +13,7 @@ application-area: [all]
## Description
`ChangeCompany` redirects the data access of one record variable to another company's table. Execution context does not move with it: Microsoft Learn states that triggers still run in the current company, not in the company passed to `ChangeCompany`. Code that knows this usually reaches for `Insert(false)` and copies the trigger's work by hand from the target company's setup. That closes only half of the gap. The runtime raises the database trigger events (`OnBeforeInsertEvent`, `OnAfterInsertEvent`, and their modify, delete, and rename counterparts) on every database operation and only passes the `RunTrigger` flag to the subscriber, so every subscriber that does not exit on `RunTrigger = false` still runs, in the calling company, against the calling company's setup, number series, and companion tables. The row lands in the target company, the side effects land in the caller, and nothing reports an error. The per-row cost of the call is a separate concern, see `changecompany-in-loop-drops-caches`.
`ChangeCompany` redirects the data access of one record variable to another company's table. Execution context does not move with it: Microsoft Learn states that triggers still run in the current company, not in the company passed to `ChangeCompany`. Code that knows this usually reaches for `Insert(false)` and copies the trigger's work by hand from the target company's setup. That closes only half of the gap. The runtime raises the database trigger events (`OnBeforeInsertEvent`, `OnAfterInsertEvent`, and their modify, delete, and rename counterparts) on every database operation and only passes the `RunTrigger` flag to the subscriber, so every subscriber that does not exit on `RunTrigger = false` still runs, in the calling company, against the calling company's setup, number series, and companion tables. The row lands in the target company, the side effects land in the caller, and nothing reports an error. The per-row cost of the call is a separate concern, see `changecompany-in-loop-drops-caches`. Upgrade code must not use `ChangeCompany` at all, see `no-changecompany-in-upgrade`.
## Best Practice

View file

@ -0,0 +1,41 @@
table 50263 "Sales Order Ext"
{
DataClassification = CustomerContent;
fields
{
field(1; "No."; Code[20]) { }
field(2; "Shipping Agent Code"; Code[10]) { TableRelation = "Shipping Agent"; }
field(3; "Legacy Carrier Code"; Code[10]) { }
}
keys
{
key(PK; "No.") { Clustered = true; }
}
}
codeunit 50261 "Upgrade All Companies"
{
Subtype = Upgrade;
trigger OnUpgradePerDatabase()
var
Company: Record Company;
SalesOrderExt: Record "Sales Order Ext";
begin
// Reaches into every company from one session. Each company also has its own
// upgrade session, triggers and subscribers run in the calling context, and a
// data error in any company aborts the whole upgrade.
if Company.FindSet() then
repeat
SalesOrderExt.ChangeCompany(Company.Name);
SalesOrderExt.SetRange("Shipping Agent Code", '');
if SalesOrderExt.FindSet(true) then
repeat
SalesOrderExt.Validate("Shipping Agent Code", SalesOrderExt."Legacy Carrier Code");
SalesOrderExt.Modify(true);
until SalesOrderExt.Next() = 0;
until Company.Next() = 0;
end;
}

View file

@ -0,0 +1,96 @@
table 50262 "Sales Order Ext"
{
DataClassification = CustomerContent;
fields
{
field(1; "No."; Code[20]) { }
field(2; "Shipping Agent Code"; Code[10]) { TableRelation = "Shipping Agent"; }
field(3; "Legacy Carrier Code"; Code[10]) { }
}
keys
{
key(PK; "No.") { Clustered = true; }
}
}
codeunit 50260 "Upgrade Current Company"
{
Subtype = Upgrade;
// The platform runs this trigger once per company, in that company's own session.
trigger OnUpgradePerCompany()
begin
UpgradeShippingAgentCodes();
end;
local procedure UpgradeShippingAgentCodes()
var
SalesOrderExt: Record "Sales Order Ext";
UpgradeTag: Codeunit "Upgrade Tag";
begin
if UpgradeTag.HasUpgradeTag(ShippingAgentUpgradeTag()) then
exit;
// Only the current company's rows; triggers, events, and the tag all apply here.
SalesOrderExt.SetRange("Shipping Agent Code", '');
if SalesOrderExt.FindSet(true) then
repeat
SalesOrderExt.Validate("Shipping Agent Code", SalesOrderExt."Legacy Carrier Code");
SalesOrderExt.Modify(true);
until SalesOrderExt.Next() = 0;
UpgradeTag.SetUpgradeTag(ShippingAgentUpgradeTag());
end;
local procedure ShippingAgentUpgradeTag(): Code[250]
begin
exit('CONTOSO-1001-ShippingAgentCode-20260101');
end;
}
codeunit 50264 "Shipping Agent Feat. Data Upd." implements "Feature Data Update"
{
// Read-only preflight: counting rows across companies to report scope is allowed.
procedure IsDataUpdateRequired(): Boolean
var
Company: Record Company;
SalesOrderExt: Record "Sales Order Ext";
begin
if Company.FindSet() then
repeat
SalesOrderExt.ChangeCompany(Company.Name);
SalesOrderExt.SetRange("Shipping Agent Code", '');
if not SalesOrderExt.IsEmpty() then
exit(true);
until Company.Next() = 0;
exit(false);
end;
procedure ReviewData()
begin
end;
// Feature Management runs this once per company, in that company.
procedure UpdateData(FeatureDataUpdateStatus: Record "Feature Data Update Status")
var
SalesOrderExt: Record "Sales Order Ext";
begin
SalesOrderExt.SetRange("Shipping Agent Code", '');
if SalesOrderExt.FindSet(true) then
repeat
SalesOrderExt.Validate("Shipping Agent Code", SalesOrderExt."Legacy Carrier Code");
SalesOrderExt.Modify(true);
until SalesOrderExt.Next() = 0;
end;
procedure AfterUpdate(FeatureDataUpdateStatus: Record "Feature Data Update Status")
begin
end;
procedure GetTaskDescription(): Text
begin
exit('Copies legacy carrier codes to the shipping agent code.');
end;
}

View file

@ -0,0 +1,36 @@
---
bc-version: [all]
domain: upgrade
keywords: [changecompany, cross-company, onupgradepercompany, onupgradeperdatabase, feature-data-update, taskscheduler, multi-company, company-context]
technologies: [al]
countries: [w1]
application-area: [all]
---
# Upgrade code must not use ChangeCompany
## Description
The platform upgrades each company in its own context: `OnUpgradePerCompany` (like the other `PerCompany` upgrade and install triggers) runs once per company, in a separate system session opened for that company, while `PerDatabase` triggers run in a session that opens no company. Calling `ChangeCompany(<name>)` in upgrade code reaches from the company being upgraded into another company. Cross-company work bypasses the platform's per-company execution boundary, making execution order and migration ownership difficult to reason about and potentially repeating work. A cross-company read cannot assume that the other company's migration has already run, so it can observe pre-upgrade or post-upgrade data. Per-company upgrade tags are set for the current company only, so a tag set after writing to company B is recorded for company A, which can cause B's own session to repeat the migration. Execution context does not move with `ChangeCompany`: table triggers and trigger-event subscribers still run in the calling company (see `changecompany-runs-triggers-in-the-calling-company`). Data and side effects then land in different companies, and an error in another company's data aborts the current company's upgrade. The same applies to the migration path of a feature-switch data update: the `UpdateData` and `AfterUpdate` methods of a `Feature Data Update` implementation. Feature Management runs them for one company's status row, either as a task scheduled in that company or in the current session. The interface's preflight methods, `IsDataUpdateRequired` and `ReviewData`, are a separate phase that reports scope before any update is scheduled.
## Best Practice
Upgrade code operates only on the company it is running in. Put per-company data migration in `OnUpgradePerCompany` (or a helper reachable only from it), guard it with a per-company upgrade tag, and let the platform invoke it for every company. Reserve `OnUpgradePerDatabase` for tables with `DataPerCompany = false` and other database-wide state; it needs no `ChangeCompany`. A feature data update implements `UpdateData` and `AfterUpdate` against the current company only. Its read-only preflight may use `ChangeCompany` to count or inspect rows in other companies, because it migrates nothing. When code outside the upgrade pipeline must start work in other companies, schedule it in each company with `TaskScheduler.CreateTask` and the company name, as Feature Management does, rather than writing there through `ChangeCompany`. The scheduled task runs in the target company, so triggers, events, permissions, and upgrade tags all use that company.
See sample: [`no-changecompany-in-upgrade.good.al`](no-changecompany-in-upgrade.good.al).
## Anti Pattern
A loop over the `Company` table in `OnUpgradePerDatabase` or `OnUpgradePerCompany` that calls `ChangeCompany(Company.Name)` on a record or `RecordRef` and then reads, inserts, modifies, or deletes data. Another form is a `Feature Data Update` implementation whose `UpdateData` or `AfterUpdate` does the same to update every company from one task. On these paths reads are not exempt: they observe another company whose upgrade state is unknown.
Detection signal: `Record.ChangeCompany(<name>)` or `RecordRef.ChangeCompany(<name>)` with a company-name argument in a codeunit with `Subtype = Upgrade` or in any procedure transitively reachable from its upgrade triggers, or in a `Feature Data Update` implementation's `UpdateData` or `AfterUpdate` or any procedure transitively reachable from them. Do not flag `ChangeCompany` in `IsDataUpdateRequired`, `ReviewData`, or helpers reachable only from them; a helper shared with `UpdateData` or `AfterUpdate` is in scope. Do not flag the parameterless `ChangeCompany()`, which only points the variable back at the current company, or `ChangeCompany` in ordinary runtime code; `changecompany-runs-triggers-in-the-calling-company` governs that.
See sample: [`no-changecompany-in-upgrade.bad.al`](no-changecompany-in-upgrade.bad.al).
## References
- Upgrading extensions, Upgrade triggers: `PerCompany` triggers run once per company, each in its own system session for that company — https://learn.microsoft.com/en-us/dynamics365/business-central/dev-itpro/developer/devenv-upgrading-extensions
- Record.ChangeCompany method, Remarks: triggers still run in the current company — https://learn.microsoft.com/en-us/dynamics365/business-central/dev-itpro/developer/methods-auto/record/record-changecompany-method
- Interface "Feature Data Update" — https://learn.microsoft.com/en-us/dynamics365/business-central/application/system-application/interface/system.environment.configuration.feature-data-update
- `FeatureManagementImpl.UpdateData` calls the interface's `UpdateData` then `AfterUpdate`; `ReviewData` calls `IsDataUpdateRequired` and `ReviewData` — https://github.com/microsoft/BCApps/blob/main/src/System%20Application/App/Feature%20Key/src/FeatureManagementImpl.Codeunit.al
- `FeatureManagementImpl.CreateTask` schedules `Update Feature Data` with `TaskScheduler.CreateTask` for the status row's company — https://github.com/microsoft/BCApps/blob/main/src/System%20Application/App/Feature%20Key/src/FeatureManagementImpl.Codeunit.al