Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions evaluation/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,14 @@ is mandatory.

Model-facing preparation hashes case IDs, neutralizes `Good`/`Bad` object-name tokens, and removes full-line sample comments so neither the article slug, domain, nor expected outcome reveals the answer.

The core AL samples for BLOB loading, isolated shipment events, expense-page
scope filters, and stream encoding include their minimal demo tables in each
companion independently. BLOB field types and shipment status members therefore
survive preparation without relying on another sample. The stream-encoding
clean control deliberately writes UTF-8 text and downloads the existing bytes
through a default-encoding InStream: transport alone does not transcode text
and must not trigger the encoding rule.

The SCM `articles` override deliberately selects every rule in the initial
functional domain, producing nine positive cases and nine clean controls.
Business context is executable: document/status `TestField` guards,
Expand Down
12 changes: 9 additions & 3 deletions evaluation/review-fixtures.json
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@
"articles": [
"activate-new-price-calculation-handler-via-onfindsupportedsetup",
"business-state-must-have-a-domain-owner",
"calcfields-blob-before-reading-its-stream",
"check-blocked-in-referencing-code-not-in-master",
"code-must-not-change-workdate",
"custom-document-dispatch-must-not-bypass-report-selections",
Expand Down Expand Up @@ -64,7 +65,9 @@
"articles": [
"reset-ishandled-only-when-the-value-can-carry-over",
"changecompany-runs-triggers-in-the-calling-company",
"database-trigger-setup-flags-may-only-be-set-to-true"
"database-trigger-setup-flags-may-only-be-set-to-true",
"isolated-events-are-isolated-only-outside-a-write-transaction",
"isolated-event-rollback-does-not-undo-var-parameters"
]
},
"finance": {
Expand Down Expand Up @@ -165,7 +168,9 @@
"articles": [
"al-has-no-built-in-htmlencode",
"do-not-concatenate-external-text-into-setfilter",
"exposed-objects-must-be-in-a-permission-set"
"exposed-objects-must-be-in-a-permission-set",
"page-scope-filters-must-not-be-in-filtergroup-zero",
"restore-the-filtergroup-after-setting-hidden-filters"
]
},
"style": {
Expand Down Expand Up @@ -223,7 +228,8 @@
"check-http-status-before-consuming-response-body",
"check-json-null-before-converting-values",
"format-exchanged-values-with-standard-format-9",
"api-page-least-privilege-write-access"
"api-page-least-privilege-write-access",
"set-text-encoding-on-streams-exchanged-externally"
]
}
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
// Demonstration only. Shows the wrong pattern: a BLOB field is read without being calculated.

codeunit 50188 "Payload Reader Bad"
{
procedure GetPayload(EntryNo: Integer): Text
var
IntegrationLog: Record "Integration Log Demo";
PayloadInStream: InStream;
PayloadText: Text;
begin
IntegrationLog.Get(EntryNo);
// BAD: Get does not load BLOB content. The stream is empty and PayloadText stays ''.
IntegrationLog.Payload.CreateInStream(PayloadInStream, TextEncoding::UTF8);
PayloadInStream.ReadText(PayloadText);
exit(PayloadText);
end;
}

table 50188 "Integration Log Demo"
{
DataClassification = CustomerContent;

fields
{
field(1; "Entry No."; Integer) { }
field(2; Payload; Blob) { }
}

keys
{
key(PK; "Entry No.") { Clustered = true; }
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
// Demonstration only. Shows the recommended pattern: the BLOB field is calculated before its stream is read.

codeunit 50189 "Payload Reader Good"
{
procedure GetPayload(EntryNo: Integer): Text
var
IntegrationLog: Record "Integration Log Demo";
PayloadInStream: InStream;
PayloadText: Text;
begin
IntegrationLog.Get(EntryNo);
IntegrationLog.CalcFields(Payload);
IntegrationLog.Payload.CreateInStream(PayloadInStream, TextEncoding::UTF8);
PayloadInStream.ReadText(PayloadText);
exit(PayloadText);
end;
}

table 50189 "Integration Log Demo"
{
DataClassification = CustomerContent;

fields
{
field(1; "Entry No."; Integer) { }
field(2; Payload; Blob) { }
}

keys
{
key(PK; "Entry No.") { Clustered = true; }
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
---
bc-version: [all]
domain: data-modeling
keywords: [blob, calcfields, setautocalcfields, createinstream, instream, empty-stream, blob-field]
technologies: [al]
countries: [w1]
application-area: [all]
---

# Calculate a BLOB field before reading its stream

## Description

`Get`, `Find`, `FindSet`, and `Next` do not load the content of `Blob` fields. Until the field is calculated with `CalcFields` (or loaded through `SetAutoCalcFields` before the read), the in-memory BLOB is empty. `CreateInStream` on that field succeeds and returns an empty stream, so code that reads text, JSON, or an image from it gets an empty result and no error. The defect does not surface in tests that fill and read the BLOB in the same record variable.

`Media` and `MediaSet` fields are different types and are not loaded with `CalcFields`; this article covers `Blob` only.

## Best Practice

Call `CalcFields` on the BLOB field after retrieving the record and before `CreateInStream`, or call `SetAutoCalcFields` on the field before a `FindSet` loop that reads the BLOB for every row.

Do not flag code that reads a BLOB it has just written through `CreateOutStream` on the same record variable, a BLOB on a temporary record filled in the same session, or a read preceded by `SetAutoCalcFields` on that field. Writing a BLOB does not require a prior `CalcFields`.

See sample: [`calcfields-blob-before-reading-its-stream.good.al`](calcfields-blob-before-reading-its-stream.good.al).

## Anti Pattern

A record retrieved from the database followed by `<Record>.<BlobField>.CreateInStream(...)` with no `CalcFields` or `SetAutoCalcFields` on that field in between. The reader sees an empty stream and passes empty content on to the parser, export, or HTTP request that consumes it.

See sample: [`calcfields-blob-before-reading-its-stream.bad.al`](calcfields-blob-before-reading-its-stream.bad.al).

## References

[Record.CalcFields method](https://learn.microsoft.com/en-us/dynamics365/business-central/dev-itpro/developer/methods-auto/record/record-calcfields-method) (CalcFields also retrieves BLOBs) and [BigText.Read method](https://learn.microsoft.com/en-us/dynamics365/business-central/dev-itpro/developer/methods-auto/bigtext/bigtext-read-method) (a BLOB field must be calculated before it is used in AL).
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
// Demonstration only. Shows the wrong pattern: an isolated event used as an IsHandled override point.

codeunit 50182 "Invoice Export Bad"
{
procedure ExportInvoice(InvoiceNo: Code[20])
var
IsHandled: Boolean;
begin
// BAD: a subscriber can set IsHandled := true and then fail. Its table writes are rolled
// back, but IsHandled keeps the value true, so the default export is silently skipped.
OnBeforeExportInvoice(InvoiceNo, IsHandled);
if IsHandled then
exit;

ExportToDefaultChannel(InvoiceNo);
end;

local procedure ExportToDefaultChannel(InvoiceNo: Code[20])
begin
end;

[IntegrationEvent(false, false, true)]
local procedure OnBeforeExportInvoice(InvoiceNo: Code[20]; var IsHandled: Boolean)
begin
end;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
// Demonstration only. Shows the recommended pattern: the override point is a normal event; the isolated event only notifies.

codeunit 50183 "Invoice Export Good"
{
procedure ExportInvoice(InvoiceNo: Code[20])
var
IsHandled: Boolean;
begin
// Normal event: a subscriber error propagates, so IsHandled is never consumed half-applied.
OnBeforeExportInvoice(InvoiceNo, IsHandled);
if not IsHandled then
ExportToDefaultChannel(InvoiceNo);
end;

procedure NotifyInvoiceExported(InvoiceNo: Code[20])
begin
// Called after the export has been committed. By-value parameters only: a failing
// subscriber cannot change anything the publisher reads afterwards.
OnAfterInvoiceExported(InvoiceNo);
end;

local procedure ExportToDefaultChannel(InvoiceNo: Code[20])
begin
end;

[IntegrationEvent(false, false)]
local procedure OnBeforeExportInvoice(InvoiceNo: Code[20]; var IsHandled: Boolean)
begin
end;

[IntegrationEvent(false, false, true)]
local procedure OnAfterInvoiceExported(InvoiceNo: Code[20])
begin
end;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
---
bc-version: [20..]
domain: events
keywords: [isolated-event, isolated, var-parameter, ishandled, onbefore, integration-event, rollback, subscriber-error, side-effects]
technologies: [al]
countries: [w1]
application-area: [all]
---

# An isolated event's rollback does not undo var parameters or other in-memory state

## Description

When a subscriber to an isolated event fails, the platform rolls back only the `Insert`, `Modify`, and `Delete` calls it made on normal tables. Everything else the subscriber changed before the error survives: values assigned to `var` parameters (including `IsHandled`), fields of a `var` record that were never written, globals of single-instance codeunits, temporary-table contents, and external effects such as HTTP calls. Execution then continues with the next subscriber and returns to the publisher as if nothing went wrong.

Reviewers and authoring agents tend to read "isolated" as "a failed subscriber leaves no trace", so they design isolated events with `var` result parameters or an `IsHandled` switch. A subscriber that sets `IsHandled := true` and then errors makes the publisher skip its default behaviour, even though the subscriber's own work was rolled back.

## Best Practice

Use isolated events for notifications: pass parameters by value and let subscribers act on their own data. When the publisher needs a decision or a result from extensions, publish a normal (non-isolated) event so subscriber errors propagate and the publisher never consumes a half-applied result.

If an isolated event must carry `var` data for compatibility, the publisher must validate the returned values before using them and must not let a returned flag bypass required logic.

See sample: [`isolated-event-rollback-does-not-undo-var-parameters.good.al`](isolated-event-rollback-does-not-undo-var-parameters.good.al).

## Anti Pattern

An event declared with `Isolated = true` whose signature includes `var IsHandled`, a `var` result value, or a `var` record the publisher reads afterwards, where the publisher changes its behaviour based on those values. A failing subscriber can leave them modified while its database writes are rolled back, so the publisher acts on a decision nobody completed.

Changing the `Isolated` flag of an event that has already shipped is a separate contract problem covered by [do-not-change-shipped-event-attribute-flags](do-not-change-shipped-event-attribute-flags.md); this article is about the design of new isolated events.

See sample: [`isolated-event-rollback-does-not-undo-var-parameters.bad.al`](isolated-event-rollback-does-not-undo-var-parameters.bad.al).

## References

[Isolated events in AL, Rollback](https://learn.microsoft.com/en-us/dynamics365/business-central/dev-itpro/developer/devenv-events-isolated#rollback): only Modify/Delete/Insert on normal tables are rolled back; HTTP calls, variable changes, and single-instance codeunit members are not, and a `var` integer modified by a failing subscriber keeps its change.
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
// Demonstration only. Shows the wrong pattern: an isolated event raised inside an open write transaction.

codeunit 50180 "Shipment Confirmation Bad"
{
procedure ConfirmShipment(var ShipmentHeader: Record "Shipment Header Demo")
begin
ShipmentHeader.TestField("Carrier Code");
ShipmentHeader.Status := ShipmentHeader.Status::Confirmed;
ShipmentHeader.Modify(true);

// BAD: the Modify above is not committed, so the platform runs this "isolated" event
// as a normal event. A failing subscriber rolls back the confirmation as well.
OnAfterConfirmShipment(ShipmentHeader);
end;

[IntegrationEvent(false, false, true)]
local procedure OnAfterConfirmShipment(ShipmentHeader: Record "Shipment Header Demo")
begin
end;
}

table 50180 "Shipment Header Demo"
{
DataClassification = CustomerContent;

fields
{
field(1; "No."; Code[20]) { }
field(2; "Carrier Code"; Code[10]) { }
field(3; Status; Option)
{
OptionMembers = Open,Confirmed;
}
}

keys
{
key(PK; "No.") { Clustered = true; }
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
// Demonstration only. Shows the recommended pattern: the isolated event is raised from a context that has written nothing.

codeunit 50181 "Shipment Confirmation Good"
{
procedure ConfirmShipment(var ShipmentHeader: Record "Shipment Header Demo")
begin
ShipmentHeader.TestField("Carrier Code");
ShipmentHeader.Status := ShipmentHeader.Status::Confirmed;
ShipmentHeader.Modify(true);
end;

// Called by the page action or job queue entry after ConfirmShipment has completed and its
// transaction has ended. This procedure only reads, so each subscriber runs in its own
// transaction and a failing subscriber cannot undo or block anything.
procedure NotifyShipmentConfirmed(ShipmentNo: Code[20])
var
ShipmentHeader: Record "Shipment Header Demo";
begin
ShipmentHeader.Get(ShipmentNo);
ShipmentHeader.TestField(Status, ShipmentHeader.Status::Confirmed);
OnAfterShipmentConfirmed(ShipmentHeader);
end;

[IntegrationEvent(false, false, true)]
local procedure OnAfterShipmentConfirmed(ShipmentHeader: Record "Shipment Header Demo")
begin
end;
}

table 50181 "Shipment Header Demo"
{
DataClassification = CustomerContent;

fields
{
field(1; "No."; Code[20]) { }
field(2; "Carrier Code"; Code[10]) { }
field(3; Status; Option)
{
OptionMembers = Open,Confirmed;
}
}

keys
{
key(PK; "No.") { Clustered = true; }
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
---
bc-version: [20..]
domain: events
keywords: [isolated-event, isolated, write-transaction, commit, modify, uncommitted-write, subscriber-error, rollback, integration-event]
technologies: [al]
countries: [w1]
application-area: [all]
---

# Isolated events are isolated only when no write transaction is open

## Description

Setting `Isolated = true` on a `BusinessEvent`, `IntegrationEvent`, or `InternalEvent` (runtime 9.0 / BC20 and later) runs each subscriber in its own transaction: a failing subscriber is rolled back and the next subscriber, then the caller, continue. The platform only does this when the publisher is raised from a read-only transaction. When the caller has already written to the database and not committed, the isolated event is invoked like a normal event, so a subscriber error fails the whole operation. The same fallback applies during extension install, uninstall, and upgrade.

An agent that sees `Isolated = true` tends to assume subscriber errors can never reach the caller, and either skips error handling around the raise or adds a `Commit()` right before it to "make isolation work". The first leaves a hidden failure path; the second splits a business transaction that was meant to be atomic.

## Best Practice

Raise an isolated event at a point where the transaction is naturally read-only or already committed, for example after the posting routine has finished and committed, or from a job queue entry, page action, or notification handler that has written nothing yet. Treat isolation as a way to keep optional, non-critical subscribers (telemetry, notifications, external sync) from breaking the publisher, not as a guarantee.

If the event must be raised in the middle of a write transaction, design it as a normal event and document that subscriber errors propagate. When error isolation is genuinely needed there, move the optional work after the commit point or into a separate `Codeunit.Run` / background task, which is easier to test than an isolated event.

See sample: [`isolated-events-are-isolated-only-outside-a-write-transaction.good.al`](isolated-events-are-isolated-only-outside-a-write-transaction.good.al).

## Anti Pattern

An isolated event raised after uncommitted `Insert`/`Modify`/`Delete` calls in the same transaction, with code or comments relying on subscriber errors being contained. The subscriber runs non-isolated and its error rolls back the caller's work.

Equally wrong is inserting `Commit()` immediately before the raise inside a posting, release, or other all-or-nothing routine just to obtain isolation: a later failure now leaves the first half of the operation committed.

Do not flag an isolated event raised from a read-only context or after the routine's own completed commit, and do not flag a normal (non-isolated) event for lacking isolation.

See sample: [`isolated-events-are-isolated-only-outside-a-write-transaction.bad.al`](isolated-events-are-isolated-only-outside-a-write-transaction.bad.al).

## References

[Isolated events in AL](https://learn.microsoft.com/en-us/dynamics365/business-central/dev-itpro/developer/devenv-events-isolated) (write transactions must be committed before the event; install and upgrade run isolated events normally) and [Types of events for extensibility](https://learn.microsoft.com/en-us/dynamics365/business-central/dev-itpro/developer/types-of-events-for-extensibility#isolated-events) (isolated events need a commit before invoking and a detailed review; `Codeunit.Run` as the alternative).
Loading
Loading