🖥️ Script hosts and scopes
Updated
How a user script gets its services, why it is built exactly this way, and what is required of anyone who adds a new way of executing scripts.
In short#
- Outside Windows there are no distributed transactions. A second
connection inside a
TransactionScopecannot enlist in the ambient transaction — an attempt at promotion throwsPlatformNotSupportedException. - So services that open a connection of their own are obliged to step out of the
ambient transaction through
TransactionScopeOption.Suppress. - The price paid for that is lost visibility: a suppressed connection does not see the caller's uncommitted rows and on SQL Server gets no "not visible" answer — it waits until the command times out.
- Resolving a model's service is bound to the ambient scope that the operation owns. A new script host is obliged to set it.
Why it is Suppress everywhere#
The test runner wraps every case in a TransactionScope that deliberately never
completes — the automatic rollback relies on that. Any service that opens its own
connection inside it would try to enlist in the same transaction, and that means
promotion to a distributed one — unavailable off Windows.
Hence the Suppress in NumberSequenceService, TranslationResolver,
RowSecurityService, LanguageContext and TestHarness. That is correct and
is meant to stay that way.
The trap: a suppressed read waits on its own caller#
The suppressed connection reads a row the caller has only just created and
has not committed yet. Under READ COMMITTED, SQL Server does not answer "not
visible" — it waits for the X lock to be released until the command times
out, and then fails the call.
On a clean database, ExistsCommittedAsync (the number sequence) and
LoadFieldsAsync (a table's translatable fields) ask about exactly such rows
and time out.
The technique. Cap the read with a short timeout and treat the refusal as the answer — a row that cannot be read without waiting is by definition not committed:
db.Database.SetCommandTimeout(TimeSpan.FromSeconds(2));
try { … } catch { /* locked by the ambient transaction ⇒ not committed */ }
PostgreSQL never reaches the fallback: MVCC does not block readers, an uncommitted row is simply not visible.
Nested transactions are no help here — neither savepoints nor
TransactionScope(Required): they work only within a single connection. There
are only two choices: share the connection, or do not wait.
The trap: a heavy service resolved inside a posting#
A document's OnBeforeUnpost runs inside the transition's unit of work, before
DocumentPostingService sets its savepoint for the posting chain. Resolving a
script service with many managers in its constructor at that point leaves the
ambient transaction without an active SQL transaction, and the posting fails
with "Cannot issue SAVE TRANSACTION when there is no active transaction".
The same service resolved in OnAfterPost — after the savepoint — is harmless,
which makes the cause look random.
Pre-posting work therefore goes through a thin service: TaxCalculationLifecycle
takes only IDocumentManager and IDocumentPostingService. Rule: in
OnBeforeUnpost / OnBeforePost, call services whose constructors take two or
three dependencies; leave the heavy ones for OnAfterPost.
A second source of the same message: an after-event (OnAfterInsert,
OnAfterSave) that deadlocks. The platform reports its failure as a warning
("After-event … reported: … deadlock victim") and carries on, but SQL Server
has already rolled the transaction back — every later write in the same scope
fails with the same SAVE TRANSACTION message. Look for the warning in the core
log, not at the test that failed.
The contract: a host is obliged to set the scope#
GetService<IXxx>() in a script goes off into ScriptServices →
IScriptServiceResolver. A resolver that creates a scope per resolve and
disposes of it while handing out an object assembled inside it leaves the
instance alive and its scoped dependencies dead: the first call throws
ObjectDisposedException: IServiceProvider. DictionaryManager would swallow
that and log it on every record — thousands of lines over a long run, in
which all the real errors drown.
The owner of the operation sets the scope:
using var _ = ZuloOne.Runtime.ScriptServices.UseScope(provider);
| Host | What it owns |
|---|---|
An HTTP request (Program.cs) |
context.RequestServices |
An integration test case (IntegrationTestRunner) |
the case's scope |
A job (JobExecutor) |
its own scope per job, not per scheduler tick |
With no owner the resolver still falls back to a private scope, and that branch keeps the old danger. A new script host is obliged to bind a scope — otherwise the services arrive with dead dependencies and nobody finds out.
What else is worth knowing about jobs#
TaskContext.GetService<T>()goes straight to DI, where models' contracts are not. OnlyScriptServices.Get/TryGet<T>()reaches them — through the resolver.- A job's model decides what its script sees: it is compiled within its own
model's scope of assemblies. The reference to the contracts assembly is added
unconditionally, regardless of
withScriptFramework, so aCS0246onI<Name>means "the job is in the wrong model", not "the contracts are not wired in".
Verification#
Inventory/Tests/JobPipelineTest.cs— a job resolves a model's service and then goes to the database with it; pure arithmetic is not enough, it would pass on a dead instance too.- To see a hang of this kind, poll
sys.dm_exec_requestsduring the hang, joining it withsys.dm_exec_connectionsandsys.dm_exec_sql_text(most_recent_sql_handle)— the blocking session is asleep, it no longer has a request of its own. The victims havewait_type = LCK_M_S; the chain is sometimes two links long.
Where next#
- Chain of Command — the same page covers handlers resolving by name.
- The posting boundary.