mirror of
https://github.com/microsoft/BCQuality.git
synced 2026-10-05 14:46:55 +01:00
Process Context via manual event subscriber pattern (#145)
* Process Context via manual event subscriber pattern * improve knowledge article
This commit is contained in:
parent
c39f723cae
commit
7a41d25bbd
3 changed files with 182 additions and 0 deletions
|
|
@ -0,0 +1,72 @@
|
|||
// Demonstration-only AL. Not compiled by CI; illustrates the article.
|
||||
|
||||
// Anti-pattern 1: the context is kept in single-instance state.
|
||||
codeunit 50545 "Process State Bad Sample"
|
||||
{
|
||||
SingleInstance = true;
|
||||
|
||||
var
|
||||
ProcessRunning: Boolean;
|
||||
|
||||
procedure SetProcessRunning(NewProcessRunning: Boolean)
|
||||
begin
|
||||
ProcessRunning := NewProcessRunning;
|
||||
end;
|
||||
|
||||
procedure IsProcessRunning(): Boolean
|
||||
begin
|
||||
exit(ProcessRunning);
|
||||
end;
|
||||
}
|
||||
|
||||
codeunit 50546 "Process Driver Bad Sample"
|
||||
{
|
||||
procedure Run(DocumentNo: Code[20])
|
||||
var
|
||||
ProcessState: Codeunit "Process State Bad Sample";
|
||||
begin
|
||||
ProcessState.SetProcessRunning(true);
|
||||
RunSharedCode(DocumentNo);
|
||||
// An error above never reaches this line. The database writes roll
|
||||
// back, the single-instance variable does not: ProcessRunning stays
|
||||
// true until the company is closed, so every later run in this session
|
||||
// is treated as part of the process.
|
||||
ProcessState.SetProcessRunning(false);
|
||||
end;
|
||||
|
||||
local procedure RunSharedCode(DocumentNo: Code[20])
|
||||
begin
|
||||
end;
|
||||
}
|
||||
|
||||
// Anti-pattern 2: the context stays private. Flag and driver look like the
|
||||
// good sample, but the query is internal, so only the owning app can ever ask.
|
||||
codeunit 50547 "Process Ctx Bad Sample"
|
||||
{
|
||||
internal procedure IsProcessRunning(): Boolean
|
||||
var
|
||||
IsRunning: Boolean;
|
||||
begin
|
||||
OnCheckProcessRunning(IsRunning);
|
||||
exit(IsRunning);
|
||||
end;
|
||||
|
||||
[InternalEvent(false)]
|
||||
local procedure OnCheckProcessRunning(var IsRunning: Boolean)
|
||||
begin
|
||||
end;
|
||||
}
|
||||
|
||||
reportextension 50548 "Shared Report Ext Bad Sample" extends "Standard Sales - Invoice"
|
||||
{
|
||||
trigger OnPreReport()
|
||||
begin
|
||||
// No callable query exists, so the extension infers the context from
|
||||
// something it hopes only that process does - here, running without a
|
||||
// UI. The guess is wrong for every other background run, and breaks
|
||||
// silently the first time the owning app changes how it works.
|
||||
if GuiAllowed() then
|
||||
exit;
|
||||
// ... behaviour that was meant to apply only inside that process ...
|
||||
end;
|
||||
}
|
||||
|
|
@ -0,0 +1,70 @@
|
|||
// Demonstration-only AL. Not compiled by CI; illustrates the article.
|
||||
|
||||
// The published context API - the entire public surface of the pattern.
|
||||
// Any dependent extension may call IsProcessRunning; nothing else is exposed.
|
||||
codeunit 50540 "Process Context Good Sample"
|
||||
{
|
||||
procedure IsProcessRunning(): Boolean
|
||||
var
|
||||
IsRunning: Boolean;
|
||||
begin
|
||||
OnCheckProcessRunning(IsRunning);
|
||||
exit(IsRunning);
|
||||
end;
|
||||
|
||||
// InternalEvent: only this app can subscribe, which is all the pattern
|
||||
// needs. local: only this codeunit can raise it.
|
||||
[InternalEvent(false)]
|
||||
local procedure OnCheckProcessRunning(var IsRunning: Boolean)
|
||||
begin
|
||||
end;
|
||||
}
|
||||
|
||||
// The flag - implementation, not API, hence Access = Internal. It stores
|
||||
// nothing between runs: being bound is the state.
|
||||
codeunit 50541 "Process Flag Good Sample"
|
||||
{
|
||||
Access = Internal;
|
||||
EventSubscriberInstance = Manual;
|
||||
|
||||
[EventSubscriber(ObjectType::Codeunit, Codeunit::"Process Context Good Sample", 'OnCheckProcessRunning', '', false, false)]
|
||||
local procedure SetProcessRunning(var IsRunning: Boolean)
|
||||
begin
|
||||
IsRunning := true;
|
||||
end;
|
||||
}
|
||||
|
||||
// The app that drives the process claims the context for exactly its own run.
|
||||
codeunit 50542 "Process Driver Good Sample"
|
||||
{
|
||||
procedure Run(DocumentNo: Code[20])
|
||||
var
|
||||
ProcessFlag: Codeunit "Process Flag Good Sample";
|
||||
begin
|
||||
// A fresh instance, bound for exactly this call. If the shared code
|
||||
// errors, the stack unwinds and takes the binding with it - nothing to reset.
|
||||
BindSubscription(ProcessFlag);
|
||||
RunSharedCode(DocumentNo);
|
||||
end;
|
||||
|
||||
local procedure RunSharedCode(DocumentNo: Code[20])
|
||||
begin
|
||||
// A base application report, a posting routine, or any other object
|
||||
// that extensions hook into - including a customer's own replacement.
|
||||
end;
|
||||
}
|
||||
|
||||
// An extension hooked into that shared code can now ask the question directly
|
||||
// instead of guessing which process is driving the run. The hook happens to be
|
||||
// a report extension here; a subscriber on any other shared object is the same.
|
||||
reportextension 50543 "Shared Report Ext Good Sample" extends "Standard Sales - Invoice"
|
||||
{
|
||||
trigger OnPreReport()
|
||||
var
|
||||
ProcessContext: Codeunit "Process Context Good Sample";
|
||||
begin
|
||||
if not ProcessContext.IsProcessRunning() then
|
||||
exit;
|
||||
// ... behaviour that applies only inside that process ...
|
||||
end;
|
||||
}
|
||||
|
|
@ -0,0 +1,40 @@
|
|||
---
|
||||
bc-version: [all]
|
||||
domain: events
|
||||
keywords: [bindsubscription, manual-binding, eventsubscriberinstance, internalevent, singleinstance, process-context, running-flag, scoped-state, rollback]
|
||||
technologies: [al]
|
||||
countries: [w1]
|
||||
application-area: [all]
|
||||
---
|
||||
|
||||
# Expose process context through a manually bound flag, not a single-instance boolean
|
||||
|
||||
> Contributions welcome — open a PR to refine or extend this article.
|
||||
|
||||
## Description
|
||||
|
||||
When an extension drives a process over shared code — a base application report, a posting routine — other extensions hooked into that code cannot tell whether a run belongs to that process: AL keeps no ambient "current process", so the driving app has to publish the context itself. The reflex answer, a `SingleInstance` codeunit holding a boolean set at the start of the run and cleared at the end, is unsafe: single-instance variables are not part of the database transaction, so a failed run rolls back the writes but not the flag, which stays `true` until the company is closed and marks every later run in the session as part of the process. A manual event binding carries the same signal safely, because the platform ties its lifetime to a variable's scope instead of to cleanup code that has to run.
|
||||
|
||||
## Best Practice
|
||||
|
||||
Publish the context as a query and let the binding itself be the state. One procedure is public; everything behind it is internal:
|
||||
|
||||
- A public context codeunit exposes `IsProcessRunning(): Boolean`, which raises an `[InternalEvent]` publisher taking a `var Boolean` and returns what comes back — the entire public surface. The publisher is internal because only the owning app subscribes, `local` because only this codeunit raises it.
|
||||
- A second codeunit, `Access = Internal` with `EventSubscriberInstance = Manual`, subscribes to that event and sets the boolean to `true`. Internal keeps it out of the API and stops other apps binding it to forge the context; it stores nothing between runs — being bound *is* the state.
|
||||
- The driving process calls `BindSubscription` on a variable whose scope is exactly the span it wants to claim: a local in the procedure that drives the run, or a global on an object that lives exactly as long as the run. While that variable is alive the query answers `true`; when it leaves scope — normally, or because an error unwound the call stack — the platform removes the binding and the query answers `false` again.
|
||||
|
||||
Bind a fresh instance per run rather than reusing one: the platform refuses to bind the same instance twice but accepts several instances of the same codeunit, so nesting and re-entrancy need no counter. The binding is session-scoped, so work the process starts in another session — a background session, a page background task, a job queue entry — cannot see it; pass the context explicitly there.
|
||||
|
||||
See sample: `expose-process-context-via-manually-bound-flag.good.al`.
|
||||
|
||||
## Anti Pattern
|
||||
|
||||
Two shapes.
|
||||
|
||||
First, the single-instance boolean — the failure described above. Detection: a `SingleInstance = true` codeunit with a boolean set before a process and cleared after it, read by other code to decide whether that process is running.
|
||||
|
||||
Second, the context kept private: the driving app arranges its own marker — typically a manually bound subscriber on an event added for its benefit alone — and offers no query, or only an `internal` one. Other extensions are left inferring the context from side effects, request-page values, or record state, which breaks silently the first time the process changes. Detection: a manual binding used purely as an internal run marker, with no public query procedure over it.
|
||||
|
||||
The mirror-image anti-pattern belongs to the reviewer: flagging the `BindSubscription` here as a leaked binding because no `UnbindSubscription` follows it. Scope release is the mechanism, not an omission — see `microsoft/knowledge/events/choose-static-vs-manual-subscribers-deliberately.md`, whose leak case is an instance parked on a `SingleInstance` global that never leaves scope.
|
||||
|
||||
See sample: `expose-process-context-via-manually-bound-flag.bad.al`.
|
||||
Loading…
Add table
Add a link
Reference in a new issue