Merge pull request #11 from Drakonian/TransactionAndErrorHandling

Transaction and error handling in BC AL - new knowledge articles
This commit is contained in:
Jesper Schulz-Wedde 2026-04-24 09:49:44 +02:00 committed by GitHub
commit b8c543dedb
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
19 changed files with 415 additions and 10 deletions

View file

@ -1,15 +1,14 @@
codeunit 50129 "Perf Sample CommitInLoop Bad"
{
procedure ReleaseAllOrders()
procedure NormalizeCustomerNames()
var
SalesHeader: Record "Sales Header";
Customer: Record Customer;
begin
SalesHeader.SetRange(Status, SalesHeader.Status::Open);
if SalesHeader.FindSet() then
if Customer.FindSet(true) then
repeat
SalesHeader.Status := SalesHeader.Status::Released;
SalesHeader.Modify();
Customer.Name := UpperCase(Customer.Name);
Customer.Modify();
Commit();
until SalesHeader.Next() = 0;
until Customer.Next() = 0;
end;
}

View file

@ -0,0 +1,21 @@
codeunit 50128 "Perf Sample CommitInLoop Good"
{
procedure NormalizeCustomerNames()
var
Customer: Record Customer;
RowsInChunk: Integer;
ChunkSize: Integer;
begin
ChunkSize := 500;
if Customer.FindSet(true) then
repeat
Customer.Name := UpperCase(Customer.Name);
Customer.Modify();
RowsInChunk += 1;
if RowsInChunk >= ChunkSize then begin
Commit();
RowsInChunk := 0;
end;
until Customer.Next() = 0;
end;
}

View file

@ -1,7 +1,7 @@
---
bc-version: [all]
domain: performance
keywords: [commit, loop, transaction, lock]
keywords: [commit, loop, transaction, lock, checkpoint, codeunit-run]
technologies: [al]
countries: [w1]
application-area: [all]
@ -13,11 +13,13 @@ application-area: [all]
## Description
Commit ends the current transaction. Calling it inside a loop produces one transaction per iteration and loses the ability to roll back the whole operation atomically. It also interferes with the platform's ability to batch write operations. The original motivation — releasing locks during a long batch — is better served by splitting the batch into explicit checkpoints that each process a bounded number of rows.
Commit ends the current write transaction. Calling it inside a per-row loop produces one transaction per iteration and loses the ability to roll back the whole operation atomically; it also interferes with the platform's ability to batch write operations. Most loops need no explicit Commit at all — AL auto-commits the enclosing code module on successful completion (see `understand-implicit-transaction-boundary.md`). When the batch is too large for one transaction, the fix is not a per-row Commit but bounded checkpoints that each process N rows.
## Best Practice
If the batch is large enough that a single transaction is untenable, process it in checkpoints driven by an outer loop that each time picks up the next N rows. Commit once per checkpoint at a clearly defined safe boundary, not inside the per-row loop.
If the batch is large enough that a single transaction is untenable, process it in checkpoints driven by an outer loop that each time picks up the next N rows. Commit once per checkpoint at a clearly defined safe boundary, not inside the per-row loop. Wrapping each chunk in `Codeunit.Run` gives the same effect with native rollback on failure — see `codeunit-run-as-atomic-sub-operation.md`.
See sample: `avoid-commit-inside-loops.good.al`.
## Anti Pattern

View file

@ -0,0 +1,12 @@
codeunit 50144 "Perf Sample AtomicSub Bad"
{
procedure ApplyDiscountToSelection(var Customer: Record Customer)
begin
if Customer.FindSet(true) then
repeat
Customer."Customer Price Group" := 'VIP';
Customer.Modify(true);
Commit();
until Customer.Next() = 0;
end;
}

View file

@ -0,0 +1,29 @@
codeunit 50142 "Perf Sample AtomicSub Good"
{
procedure ApplyDiscountToSelection(var Customer: Record Customer)
var
ApplyOne: Codeunit "Perf Sample Apply Discount";
begin
if Customer.FindSet() then
repeat
ClearLastError();
if not ApplyOne.Run(Customer) then
LogSkipped(Customer."No.", GetLastErrorText());
until Customer.Next() = 0;
end;
local procedure LogSkipped(CustomerNo: Code[20]; ErrorText: Text)
begin
end;
}
codeunit 50143 "Perf Sample Apply Discount"
{
TableNo = Customer;
trigger OnRun()
begin
Rec.Validate("Customer Price Group", 'VIP');
Rec.Modify(true);
end;
}

View file

@ -0,0 +1,26 @@
---
bc-version: [all]
domain: performance
keywords: [codeunit-run, atomic, rollback, transaction, sub-transaction, try-pattern, implicit-commit]
technologies: [al]
countries: [w1]
application-area: [all]
---
# Use Codeunit.Run to bound an atomic sub-operation
## Description
`Codeunit.Run(ID)` is the AL-idiomatic way to run a unit of work as an atomic sub-operation with its own transactional boundary. When the return value is captured — `if Codeunit.Run(MyCodeunit) then ...` — the runtime treats the codeunit as a unit: on successful completion it performs an implicit commit of the codeunit's database changes; on error it rolls those changes back and the caller receives `false`. Per the platform reference, "any changes done to the database will be committed at the end of the codeunit, unless an error occurs." The caller decides how to react — compensate, surface an error, continue — without having to manage transactions by hand.
## Best Practice
When a piece of work must either complete fully or have no effect, put it in its own codeunit and invoke it via `Codeunit.Run`, capturing the return. Use `if not Codeunit.Run(X) then Error(...)` to abort and unwind; use the plain boolean branch to react to failure without aborting the caller. This replaces the SQL-style `BEGIN TRAN / COMMIT / ROLLBACK` habit with a pattern the AL runtime implements natively. Do not confuse `Codeunit.Run` with `[TryFunction]` — both catch errors, but only `Codeunit.Run` rolls back database changes on failure (see `use-tryfunction-for-error-catching-not-rollback.md`). Note that if the caller is already in a write transaction, the platform requires a `Commit()` before `Codeunit.Run` — the sub-operation cannot nest inside an open transaction (see `codeunit-run-requires-prior-commit-inside-transaction.md`).
See sample: `codeunit-run-as-atomic-sub-operation.good.al`.
## Anti Pattern
Inlining the work in the caller and sprinkling `Commit()` to simulate sub-transaction boundaries. The caller's enclosing transaction is fused to the sub-work; any Commit between checkpoints survives subsequent errors, and any errors after a Commit cannot be cleanly unwound. Per-row Commits (see `avoid-commit-inside-loops.md`) are a frequent symptom.
See sample: `codeunit-run-as-atomic-sub-operation.bad.al`.

View file

@ -0,0 +1,27 @@
codeunit 50147 "Perf Sample OpenTxnRun Bad"
{
procedure ApplyDiscountToSelection(var Customer: Record Customer)
var
ApplyOne: Codeunit "Perf Sample OpenTxnRun Apply";
RunLog: Record "Custom Run Log";
begin
if Customer.FindSet() then
repeat
RunLog.Init();
RunLog."Customer No." := Customer."No.";
RunLog.Insert();
if not ApplyOne.Run(Customer) then;
until Customer.Next() = 0;
end;
}
codeunit 50148 "Perf Sample OpenTxnRun Apply"
{
TableNo = Customer;
trigger OnRun()
begin
Rec.Validate("Customer Price Group", 'VIP');
Rec.Modify(true);
end;
}

View file

@ -0,0 +1,37 @@
codeunit 50145 "Perf Sample DeferredLog Good"
{
procedure ApplyDiscountToSelection(var Customer: Record Customer)
var
ApplyOne: Codeunit "Perf Sample DeferredLog Apply";
FailedCustomerNos: List of [Code[20]];
FailureReasons: List of [Text];
Index: Integer;
begin
if Customer.FindSet() then
repeat
ClearLastError();
if not ApplyOne.Run(Customer) then begin
FailedCustomerNos.Add(Customer."No.");
FailureReasons.Add(GetLastErrorText());
end;
until Customer.Next() = 0;
for Index := 1 to FailedCustomerNos.Count() do
WriteFailureLog(FailedCustomerNos.Get(Index), FailureReasons.Get(Index));
end;
local procedure WriteFailureLog(CustomerNo: Code[20]; Reason: Text)
begin
end;
}
codeunit 50146 "Perf Sample DeferredLog Apply"
{
TableNo = Customer;
trigger OnRun()
begin
Rec.Validate("Customer Price Group", 'VIP');
Rec.Modify(true);
end;
}

View file

@ -0,0 +1,26 @@
---
bc-version: [all]
domain: performance
keywords: [codeunit-run, commit, write-transaction, nesting, loop, runtime-error]
technologies: [al]
countries: [w1]
application-area: [all]
---
# Commit before Codeunit.Run when the caller already holds a write transaction
## Description
`Codeunit.Run` cannot nest inside an open write transaction. Per the platform reference, "If you're already in a transaction you must commit first before calling `Codeunit.Run`." The platform enforces this at runtime: the first call dies with an error, not at compile time. The rule most often surfaces in a loop that pairs outer-scope writes — progress records, audit log entries, failure markers — with a per-item `Codeunit.Run`: the first outer write opens a transaction, the subsequent `Codeunit.Run` throws. `[CommitBehavior]` does not silence this, because the implicit commit inside `Codeunit.Run` is exempt from the attribute: "The `CommitBehavior` only applies to explicit commits, not implicit commits done as part of [Codeunit.Run]." `[TryFunction]` is not a substitute either: a try method catches errors but does not open its own rollback boundary (see `use-tryfunction-for-error-catching-not-rollback.md`).
## Best Practice
For the `Codeunit.Run` atomic-sub-operation pattern (see `codeunit-run-as-atomic-sub-operation.md`) to work in a loop, keep the outer scope **read-only**. Move per-iteration writes — progress updates, logging, audit entries — into the sub-codeunit so they commit or roll back together with the per-item work. If logging must live outside the atomic boundary, defer it: collect failure info in memory during the loop (a `List of [Text]`, a temporary record, local variables) and write it in one pass after the loop ends, when no outer write transaction is open.
See sample: `codeunit-run-requires-prior-commit-inside-transaction.good.al`.
## Anti Pattern
Inserting `Commit()` before each `Codeunit.Run` to silence the runtime error. The error goes away, but the outer scope now commits per iteration — the behavior `avoid-commit-inside-loops.md` exists to warn against. Attempting to silence the implicit commit inside the sub-codeunit with `[CommitBehavior(CommitBehavior::Ignore)]` also fails: the attribute does not apply to `Codeunit.Run`'s implicit commit. Conditioning the Commit on `Database.IsInWriteTransaction()` (runtime 11.0+) is another version of the same trap — the method has legitimate uses for diagnostics and library code that genuinely cannot control its caller, but branching production flow on runtime transaction state typically signals unclear ownership that would be better fixed by restructuring the caller so transaction state is predictable.
See sample: `codeunit-run-requires-prior-commit-inside-transaction.bad.al`.

View file

@ -0,0 +1,22 @@
---
bc-version: [all]
domain: performance
keywords: [commit, transaction, implicit-commit, write-transaction, runtime, boundary]
technologies: [al]
countries: [w1]
application-area: [all]
---
# AL auto-commits when code execution completes
## Description
In AL, write transactions are managed by the runtime, not by the developer. When AL code begins executing from an entry point — an outermost trigger, a codeunit invoked via `Codeunit.Run`, a report, a page action — the runtime opens a write transaction on the first database write. When that execution completes without error, the runtime commits automatically; if that execution errors, uncommitted writes are rolled back. Explicit `Commit()` is not how write transactions are *started*; it is how a single execution is *split* into multiple transactions. Per the platform reference, "The Commit method separates write transactions in an AL code module."
## Best Practice
Default to no explicit `Commit()`. Let the runtime open and close the transaction around the execution. Reach for `Commit()` only when the execution has a real reason to persist partial progress — for example, a long batch that must release locks between checkpoints (see `avoid-commit-inside-loops.md`), or work that calls an external service and must persist the resulting handle before continuing with operations that may fail independently. If a stretch of work needs to either complete fully or have no effect, prefer `Codeunit.Run` over manual Commit choreography (see `codeunit-run-as-atomic-sub-operation.md`).
## Anti Pattern
Sprinkling `Commit()` defensively — at the end of a procedure, after every Modify, or "just to be safe" — reflects a SQL-style mental model that does not apply here. Every stray Commit shortens the rollback window: work before the Commit survives later errors the developer almost certainly intended to unwind. A Commit without a specific reason is a bug waiting to surface.

View file

@ -0,0 +1,17 @@
codeunit 50156 "Perf Sample TryFunc Bad"
{
procedure ApplyDiscountAttempt(var Customer: Record Customer)
begin
if not TryApplyDiscount(Customer) then
Message('Discount not applied');
end;
[TryFunction]
local procedure TryApplyDiscount(var Customer: Record Customer)
begin
Customer.Validate("Customer Price Group", 'VIP');
Customer.Modify(true);
if Customer."Credit Limit (LCY)" <= 0 then
Error('Customer %1 not eligible', Customer."No.");
end;
}

View file

@ -0,0 +1,23 @@
codeunit 50155 "Perf Sample TryFunc Good"
{
procedure ParseAndProcess(Payload: Text)
var
ParsedValue: Decimal;
begin
ClearLastError();
if not TryParseDecimal(Payload, ParsedValue) then begin
LogParseFailure(Payload, GetLastErrorText());
exit;
end;
end;
[TryFunction]
local procedure TryParseDecimal(Input: Text; var Result: Decimal)
begin
Evaluate(Result, Input);
end;
local procedure LogParseFailure(Payload: Text; Reason: Text)
begin
end;
}

View file

@ -0,0 +1,28 @@
---
bc-version: [all]
domain: performance
keywords: [try-function, try-method, error-handling, rollback, atomic, exception, get-last-error, session-buffer]
technologies: [al]
countries: [w1]
application-area: [all]
---
# Use [TryFunction] for error catching, Codeunit.Run for atomic rollback
## Description
`[TryFunction]` annotates a method so that errors raised inside it can be caught by the caller instead of propagating. Per the platform reference, "changes to the database that are made with a try method aren't rolled back" — the attribute catches the error; it does not unwind database state. This is the critical distinction from `Codeunit.Run`, which does roll back on error (see `codeunit-run-as-atomic-sub-operation.md`). A try function also only catches when its return value is used: "If the return variable for a call to a function, which is attributed with [TryFunction] isn't used, then the call isn't considered a try function call." `DoTry();` propagates errors normally; only `ok := DoTry();` or `if DoTry() then ...` catches. The return type is forced to Boolean; user-defined return types are not allowed, and the value isn't accessible inside the try method itself. On Business Central on-premises, writes inside a try method are blocked by default and raise a runtime error unless `DisableWriteInsideTryFunctions` is set to `false` on the server — SaaS has no such restriction.
## Best Practice
Reach for `[TryFunction]` when you want to catch a failure without unwinding the transaction — HTTP calls whose non-2xx responses should surface a user-friendly message, .NET interop whose exceptions you want to translate, validation or parsing routines whose errors you intend to log and continue past. Always capture the return: `if MyTry() then ... else HandleFailure(GetLastErrorText());`. When the work is transactional — writes that must either fully apply or fully revert — use `Codeunit.Run` instead. The two primitives solve different problems: one catches errors, the other bounds a rollback scope.
Use `[TryFunction]` sparingly. Each caught error writes to the session-wide `GetLastErrorText` and `GetLastErrorCallStack` buffers, and every subsequent catch overwrites the earlier state — a helper that reads `GetLastErrorText` later may see a different error than the one it intended to inspect. Prefer explicit checks (non-throwing predicates, guard conditions, upfront validation) for operations with predictable failure modes; reserve `[TryFunction]` for genuinely unpredictable failures such as network calls, third-party interop, or evaluation of user-supplied expressions. When you do catch, read `GetLastErrorText` immediately after the failed call, and call `ClearLastError` before the call if an earlier catch in the same scope could have left state behind — per the platform reference, "If you call the GetLastErrorText method immediately after you call the ClearLastError method, then an empty string is returned."
See sample: `use-tryfunction-for-error-catching-not-rollback.good.al`.
## Anti Pattern
Wrapping database writes in `[TryFunction]` expecting the writes to roll back when the method errors. They do not: the writes that succeeded before the error remain, the caller receives `false`, and the corrupted-state bug surfaces in production. A related anti-pattern is calling a try function without capturing the return (`DoTry();`), which silently strips the error-catching behavior and lets the error propagate — the code looks defensive but behaves identically to an unwrapped call. A third is defensive sprinkling: wrapping every operation that *could* theoretically error in `[TryFunction]` on the theory that catching is always safer than propagating. Each extra catch pollutes the shared error buffer and makes the diagnostic signal harder to find when something real does fail.
See sample: `use-tryfunction-for-error-catching-not-rollback.bad.al`.

View file

@ -0,0 +1,26 @@
codeunit 50151 "Sec Sample CommitBeh Bad"
{
[IntegrationEvent(true, false)]
procedure OnBeforeApplyingDiscount(var Customer: Record Customer)
begin
end;
procedure ApplyDiscount(var Customer: Record Customer)
begin
Customer."Customer Price Group" := 'VIP';
Customer.Modify(true);
OnBeforeApplyingDiscount(Customer);
if Customer."Credit Limit (LCY)" <= 0 then
Error('Customer %1 not eligible', Customer."No.");
Commit();
end;
}
codeunit 50152 "Sec Sample CommitBeh Bad Sub"
{
[EventSubscriber(ObjectType::Codeunit, Codeunit::"Sec Sample CommitBeh Bad", 'OnBeforeApplyingDiscount', '', true, true)]
local procedure NotifyOther(var Customer: Record Customer)
begin
Commit();
end;
}

View file

@ -0,0 +1,27 @@
codeunit 50149 "Sec Sample CommitBeh Good"
{
[CommitBehavior(CommitBehavior::Ignore)]
[IntegrationEvent(true, false)]
procedure OnBeforeApplyingDiscount(var Customer: Record Customer)
begin
end;
procedure ApplyDiscount(var Customer: Record Customer)
begin
Customer."Customer Price Group" := 'VIP';
Customer.Modify(true);
OnBeforeApplyingDiscount(Customer);
if Customer."Credit Limit (LCY)" <= 0 then
Error('Customer %1 not eligible', Customer."No.");
Commit();
end;
}
codeunit 50150 "Sec Sample CommitBeh Good Sub"
{
[EventSubscriber(ObjectType::Codeunit, Codeunit::"Sec Sample CommitBeh Good", 'OnBeforeApplyingDiscount', '', true, true)]
local procedure NotifyOther(var Customer: Record Customer)
begin
Commit();
end;
}

View file

@ -0,0 +1,26 @@
---
bc-version: [all]
domain: security
keywords: [commit-behavior, attribute, integration-event, subscriber, commit, atomic]
technologies: [al]
countries: [w1]
application-area: [all]
---
# Use [CommitBehavior] to protect an atomic operation from third-party commits
## Description
`[CommitBehavior(CommitBehavior::Ignore)]` and `[CommitBehavior(CommitBehavior::Error)]` are method-level attributes that restrict what an explicit `Commit()` does inside the annotated method's scope: `Ignore` silently discards the call; `Error` raises a runtime error. The behavior only lasts for that method's activation — it reverts on method exit whether the method succeeded or errored. The attribute only tightens, never loosens: a parent method running under `Error` overrides any attempt to declare `Ignore` on a nested method. The primary use case is protecting an atomic publisher method — typically an `IntegrationEvent` — from `Commit()` calls in subscribers written by third parties: "you can protect your code from commits happening in event subscriber code; typically written by a third party." The attribute applies to explicit commits only; it does not affect the implicit commit performed by `Codeunit.Run` (see `codeunit-run-requires-prior-commit-inside-transaction.md`). It combines with `[TryFunction]` — a single method may carry both attributes, and each governs its own dimension: `[CommitBehavior]` the commit policy, `[TryFunction]` the error-propagation policy (see `use-tryfunction-for-error-catching-not-rollback.md`).
## Best Practice
Annotate publisher methods whose transactional guarantees must survive extension code. The attribute is a selective guard, not a convention: most `IntegrationEvent` publishers do not need it. Events that fire from a standalone query, events fired after the publisher has already committed, informational hooks, and notification-style events are unaffected by subscriber commits. Reach for the attribute only when the publisher has uncommitted writes at the moment of firing and a premature inner commit would persist inconsistent state. Prefer `Ignore` over `Error` when the intent is "silently nullify" — an `Error` from an extension's commit would surface as a subscriber-authored dialog rather than a publisher-defined failure mode. Pair the attribute with the actual atomic-boundary logic in the publisher (validate, then `Commit` on success); a subscriber's suppressed commit remains a no-op regardless of how the publisher completes.
See sample: `commitbehavior-attribute-scopes-explicit-commits.good.al`.
## Anti Pattern
Publishing an `IntegrationEvent` from inside an atomic operation without `[CommitBehavior(CommitBehavior::Ignore)]`. A third-party subscriber that calls `Commit()` — intentionally or by accident — persists the publisher's partial state, defeating any rollback the publisher would have performed on a later validation failure. Another anti-pattern is placing the attribute on a wrapper method and calling a nested `Codeunit.Run` that writes, expecting the attribute to suppress the implicit commit: it does not. The mirror-image anti-pattern is applying the attribute reflexively to every `IntegrationEvent` regardless of context — events that fire outside an atomic sequence gain nothing from the protection, and adding it everywhere clutters the review surface and masks the publishers that genuinely need it.
See sample: `commitbehavior-attribute-scopes-explicit-commits.bad.al`.

View file

@ -0,0 +1,10 @@
codeunit 50154 "Test Sample TransModel Bad"
{
Subtype = Test;
[Test]
[TransactionModel(TransactionModel::AutoRollback)]
procedure TestPostingRoutineAutoRollback()
begin
end;
}

View file

@ -0,0 +1,21 @@
codeunit 50153 "Test Sample TransModel Good"
{
Subtype = Test;
[Test]
[TransactionModel(TransactionModel::AutoRollback)]
procedure TestLogicThatDoesNotCommit()
var
Customer: Record Customer;
begin
Customer.Init();
Customer."No." := 'T-001';
Customer.Insert(true);
end;
[Test]
[TransactionModel(TransactionModel::AutoCommit)]
procedure TestLogicThatCommitsInternally()
begin
end;
}

View file

@ -0,0 +1,26 @@
---
bc-version: [all]
domain: testing
keywords: [transactionmodel, attribute, test, autorollback, autocommit, testisolation]
technologies: [al]
countries: [w1]
application-area: [all]
---
# Match TransactionModel to the commit behavior of the code under test
## Description
`[TransactionModel(...)]` declares how a test method interacts with the database's write transaction. The attribute applies only to methods inside a codeunit with `SubType = Test` and takes one of three values: `AutoRollback`, `AutoCommit`, or `None`. The choice must match the code being exercised — in particular, whether that code calls `Commit()`. Per the platform reference, "if the code that you test includes calls to the COMMIT Method, then set the TransactionModel property on the test method to AutoCommit." Applying `AutoRollback` to a test that drives code which calls `Commit` produces a runtime error on the first Commit, not a meaningful assertion failure — the test does not complete, and the reviewer sees an infrastructure error instead of a business-logic verdict.
## Best Practice
Default to `AutoRollback`: it opens a write transaction at the start of the test, runs the test body, and rolls back at the end, leaving the database in its original state. Pick `AutoCommit` only when the code under test genuinely calls `Commit` — posting routines, job-queue handlers, integration flows — and pair that test's codeunit with a `TestIsolation`-enabled test runner so committed changes are reverted at a higher scope. Pick `None` only for read-only tests or tests that drive UI code without writing from the test method itself, for example tests that validate calculation formulas or read-only projections.
See sample: `transactionmodel-attribute-governs-test-transactions.good.al`.
## Anti Pattern
Applying `AutoRollback` to every test method without checking whether the tested business logic calls `Commit`. The test throws at the first Commit, leaving no verdict on the behavior it intended to verify; in a CI run this looks like a flake or a setup bug, not a specification mismatch. The mirror-image anti-pattern is defaulting to `AutoCommit` across the suite "to avoid the error" — without a `TestIsolation` runner this permanently dirties the test database between runs and produces order-dependent test outcomes.
See sample: `transactionmodel-attribute-governs-test-transactions.bad.al`.