Skip to content

Commit 33452e1

Browse files
Arthurvdvclaude
andauthored
docs: explain LockTable in table triggers and the convert-or-delete choice for LC0031 (#185)
* docs: explain LockTable in table triggers and the convert-or-delete choice for LC0031 Rewrite the LC0031 page with the mechanism-first voice: why LockTable is transaction-wide, what ReadIsolation changes, and how tri-state locking falls back. Add Convert or delete (BCQuality criterion), LockTable in table triggers (the reporter's question), and Code fix sections. Add Microsoft Learn blockquotes and BCQuality/Mads Gram references. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * docs: correct the LC0031 BCQuality links and complete the trigger example pairs Point the BCQuality references at the microsoft/BCQuality knowledge folder, give both trigger shapes a bad and a fixed example with the standard diagnostic comment, and fix the highlighted lines. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
1 parent b228958 commit 33452e1

1 file changed

Lines changed: 94 additions & 3 deletions

File tree

content/docs/analyzers/LinterCop/LC0031.md

Lines changed: 94 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -10,11 +10,21 @@ linkTitle = 'LC0031'
1010
ignoreObsolete = true
1111
+++
1212

13-
`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.
13+
`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.
1414

15-
`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.
15+
> 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.
1616
17-
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.
17+
[Performance Articles for Developers](https://learn.microsoft.com/en-us/dynamics365/business-central/dev-itpro/performance/performance-developer) on Microsoft Learn
18+
19+
`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.
20+
21+
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:
22+
23+
> Explicitly using the LockTable method in code maintains the same behavior, disabling optimistic reads.
24+
25+
[Tri-state locking in database](https://learn.microsoft.com/en-us/dynamics365/business-central/dev-itpro/developer/devenv-tri-state-locking) on Microsoft Learn
26+
27+
Replacing `LockTable()` with `ReadIsolation` preserves the tri-state benefits: fewer locks, higher concurrency, and fewer lock timeouts across the system.
1828

1929
### Example
2030

@@ -44,9 +54,90 @@ begin
4454
end;
4555
{{< /highlight >}}
4656

57+
### Convert or delete
58+
59+
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:
60+
61+
- **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.
62+
- **No such read follows.** The lock protects nothing. Delete the line.
63+
64+
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`:
65+
66+
{{< highlight al "hl_lines=4" >}}
67+
var
68+
Customer: Record Customer;
69+
begin
70+
Customer.ReadIsolation(IsolationLevel::ReadUncommitted);
71+
while Customer.Get("No.") do
72+
"No." := NoSeries.GetNextNo("No. Series");
73+
{{< /highlight >}}
74+
75+
The same pattern appears in `Vendor`, `Item`, `Contact`, `Employee`, `Bank Account`, `Resource`, and in the Sustainability ESG tables added in 2025.
76+
77+
### LockTable in table triggers
78+
79+
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.
80+
81+
Most trigger `LockTable()` calls in the Base Application come in two shapes.
82+
83+
**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:
84+
85+
{{< highlight al "hl_lines=3" >}}
86+
trigger OnInsert()
87+
begin
88+
LockTable(); // Use ReadIsolation instead of LockTable [LC0031]
89+
ItemJnlTemplate.Get("Journal Template Name");
90+
end;
91+
{{< /highlight >}}
92+
93+
Delete the line. The Base Application removed exactly this call from `Item Journal Line` in version 26:
94+
95+
{{< highlight al >}}
96+
trigger OnInsert()
97+
begin
98+
ItemJnlTemplate.Get("Journal Template Name");
99+
end;
100+
{{< /highlight >}}
101+
102+
**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:
103+
104+
{{< highlight al "hl_lines=5" >}}
105+
trigger OnInsert()
106+
var
107+
Attachment2: Record Attachment;
108+
begin
109+
Attachment2.LockTable(); // Use ReadIsolation instead of LockTable [LC0031]
110+
if Attachment2.FindLast() then
111+
"No." := Attachment2."No." + 1;
112+
end;
113+
{{< /highlight >}}
114+
115+
Convert the lock to `ReadIsolation` on the instance that reads:
116+
117+
{{< highlight al "hl_lines=5" >}}
118+
trigger OnInsert()
119+
var
120+
Attachment2: Record Attachment;
121+
begin
122+
Attachment2.ReadIsolation(IsolationLevel::UpdLock);
123+
if Attachment2.FindLast() then
124+
"No." := Attachment2."No." + 1;
125+
end;
126+
{{< /highlight >}}
127+
128+
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.
129+
130+
### Code fix
131+
132+
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.
133+
134+
The fix always converts and never deletes. Apply it when a dependent read follows; when the call protects nothing, delete the line instead.
135+
47136
### See also
48137

49138
- [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
139+
- [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
140+
- [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
50141
- [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
51142
- [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
52143
- [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

0 commit comments

Comments
 (0)