ZuloOne
English
Sign in
Menu

🖥️ 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.

← Developer wiki

In short#

  • Outside Windows there are no distributed transactions. A second connection inside a TransactionScope cannot enlist in the ambient transaction — an attempt at promotion throws PlatformNotSupportedException.
  • 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. Only ScriptServices.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 a CS0246 on I<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_requests during the hang, joining it with sys.dm_exec_connections and sys.dm_exec_sql_text(most_recent_sql_handle) — the blocking session is asleep, it no longer has a request of its own. The victims have wait_type = LCK_M_S; the chain is sometimes two links long.

Where next#

Was this page helpful?