diff --git a/content/docs/analyzers/LinterCop/LC0031.md b/content/docs/analyzers/LinterCop/LC0031.md index 472b339..838ecf9 100644 --- a/content/docs/analyzers/LinterCop/LC0031.md +++ b/content/docs/analyzers/LinterCop/LC0031.md @@ -10,11 +10,21 @@ linkTitle = 'LC0031' ignoreObsolete = true +++ -`LockTable()` is the traditional way to acquire an update lock before reading or modifying records. The problem is that it modifies **global session state**: once called on any record variable, every subsequent read against that table — on *any* variable instance — acquires an update lock for the remainder of the transaction. An event subscriber, a FlowField calculation, or unrelated code further down the call stack can end up locking rows it never intended to touch. +`LockTable()` does not acquire a lock. It marks the table so that every subsequent read of that table in the transaction, on *any* record variable, uses the SQL `UPDLOCK` hint until the transaction commits. An event subscriber, a FlowField calculation, or unrelated code further down the call stack ends up locking rows it never intended to touch. -`ReadIsolation`, introduced in Business Central 2023 wave 1 (v22), sets the isolation level on the **specific record variable** only. Other variables of the same table remain unaffected, keeping lock scope tight and predictable. +> If `Record.LockTable` is called on an `Item` record, all reads against that table will be done with the `UPDLOCK` hint, not just the variable it was called on. -There is another reason to make the switch: **tri-state locking** (default from v25 onward) falls back to pessimistic two-state locking the moment `LockTable()` is called anywhere in the transaction. Replacing `LockTable()` with `ReadIsolation` preserves the tri-state benefits — fewer locks, higher concurrency, and fewer lock timeouts across the system. +— [Performance Articles for Developers](https://learn.microsoft.com/en-us/dynamics365/business-central/dev-itpro/performance/performance-developer) on Microsoft Learn + +`ReadIsolation`, introduced in Business Central 2023 wave 1 (v22), sets the isolation level on the **specific record variable** only. Other variables of the same table remain unaffected. The two are not equivalent, and that difference is the point: `ReadIsolation` keeps lock scope tight and predictable. + +There is a second reason to make the switch. [Tri-state locking](https://learn.microsoft.com/en-us/dynamics365/business-central/dev-itpro/developer/devenv-tri-state-locking) (default from v25 onward) performs reads after writes optimistically, and a single `LockTable()` switches the table back to pessimistic locking for the rest of the transaction: + +> Explicitly using the LockTable method in code maintains the same behavior, disabling optimistic reads. + +— [Tri-state locking in database](https://learn.microsoft.com/en-us/dynamics365/business-central/dev-itpro/developer/devenv-tri-state-locking) on Microsoft Learn + +Replacing `LockTable()` with `ReadIsolation` preserves the tri-state benefits: fewer locks, higher concurrency, and fewer lock timeouts across the system. ### Example @@ -44,9 +54,90 @@ begin end; {{< /highlight >}} +### Convert or delete + +Microsoft's own [coding guidance](https://github.com/microsoft/BCQuality/tree/main/microsoft/knowledge/performance) endorses `LockTable` for exactly one situation: a read that a directly following `Insert`, `Modify`, or `Delete` depends on. One question decides what to do with a flagged call: + +- **A read of the same table follows, and a write depends on what it returned.** Convert: put `ReadIsolation(IsolationLevel::UpdLock)` on the instance that performs that read. +- **No such read follows.** The lock protects nothing. Delete the line. + +The modern shape for "make sure nobody else took this number" is `ReadIsolation` on the instance that reads. The Base Application's `Customer` table does this in `OnInsert`: + +{{< highlight al "hl_lines=4" >}} +var + Customer: Record Customer; +begin + Customer.ReadIsolation(IsolationLevel::ReadUncommitted); + while Customer.Get("No.") do + "No." := NoSeries.GetNextNo("No. Series"); +{{< /highlight >}} + +The same pattern appears in `Vendor`, `Item`, `Contact`, `Employee`, `Bank Account`, `Resource`, and in the Sustainability ESG tables added in 2025. + +### LockTable in table triggers + +A bare `LockTable()` in `OnInsert` or `OnDelete` binds to the same built-in method as `MyVar.LockTable()`: the compiler models no self-receiver special case, and the runtime effect is the same transaction-wide `UPDLOCK` on the table. The diagnostic is therefore reported inside triggers as well. + +Most trigger `LockTable()` calls in the Base Application come in two shapes. + +**No read follows.** A journal table locks itself in `OnInsert` and then reads an unrelated template table. The lock on the journal line table serves no read: + +{{< highlight al "hl_lines=3" >}} +trigger OnInsert() +begin + LockTable(); // Use ReadIsolation instead of LockTable [LC0031] + ItemJnlTemplate.Get("Journal Template Name"); +end; +{{< /highlight >}} + +Delete the line. The Base Application removed exactly this call from `Item Journal Line` in version 26: + +{{< highlight al >}} +trigger OnInsert() +begin + ItemJnlTemplate.Get("Journal Template Name"); +end; +{{< /highlight >}} + +**A dependent read follows.** An entry table locks itself in `OnInsert`, then a second instance reads the last number, and the insert depends on that value: + +{{< highlight al "hl_lines=5" >}} +trigger OnInsert() +var + Attachment2: Record Attachment; +begin + Attachment2.LockTable(); // Use ReadIsolation instead of LockTable [LC0031] + if Attachment2.FindLast() then + "No." := Attachment2."No." + 1; +end; +{{< /highlight >}} + +Convert the lock to `ReadIsolation` on the instance that reads: + +{{< highlight al "hl_lines=5" >}} +trigger OnInsert() +var + Attachment2: Record Attachment; +begin + Attachment2.ReadIsolation(IsolationLevel::UpdLock); + if Attachment2.FindLast() then + "No." := Attachment2."No." + 1; +end; +{{< /highlight >}} + +The Base Application still contains about 78 `LockTable()` calls in table triggers. Since version 23.5 it has removed two and added none, and the newer Microsoft apps (Business Foundation, E-Document Core, Subscription Billing, Excise Taxes) contain no `LockTable` at all. + +### Code fix + +The **ALCops: Replace LockTable() with ReadIsolation** code fix rewrites the call to `ReadIsolation(IsolationLevel::UpdLock)` on the same receiver: `MyVar.LockTable()` becomes `MyVar.ReadIsolation(...)`, `Rec.LockTable()` becomes `Rec.ReadIsolation(...)`, and a bare `LockTable()` inside a table or tableextension becomes a bare `ReadIsolation(...)`. It drops the `Wait` and `VersionCheck` arguments of `LockTable(true, true)`, because `ReadIsolation` has no equivalent. + +The fix always converts and never deletes. Apply it when a dependent read follows; when the call protects nothing, delete the line instead. + ### See also - [Record instance isolation level](https://learn.microsoft.com/en-us/dynamics365/business-central/dev-itpro/developer/devenv-read-isolation) — Microsoft Learn reference for `ReadIsolation` and isolation levels +- [Prefer ReadIsolation over LockTable for reads](https://github.com/microsoft/BCQuality/blob/main/microsoft/knowledge/performance/prefer-readisolation-over-locktable-for-reads.md) and [Do not LockTable in a read-only procedure](https://github.com/microsoft/BCQuality/blob/main/microsoft/knowledge/performance/do-not-locktable-in-read-only-procedure.md) — Microsoft's BCQuality guidance on when `LockTable` is still justified +- [BC Internals: Tri-state locking](https://bcinternals.com/posts/tri-state-locking/) — The runtime team on the locking model, with the options listed in order of preference - [Locking Scope: Differences between LockTable and ReadIsolation](https://www.keytogoodcode.com/post/locking-scope-differences-between-locktable-and-readisolation) — SQL-level analysis showing how `LockTable` leaks locks to unrelated code - [Optimized Locking Feature vs Dynamics 365 Business Central](https://duiliotacconi.com/2025/05/30/optimized-locking-feature-vs-dynamics-365-business-central/) — Tri-state locking, RCSI, and transaction isolation in practice - [Rec.LockTable: Good Practice or Bad Practice?](https://waldo.be/2024/03/28/rec-locktable-good-practice-or-bad-practice/) — How a long-standing best practice became an anti-pattern