Skip to content

Execution contexts

Mirko Da Corte edited this page Aug 25, 2026 · 7 revisions

Scrinium needs a bit of ambient per-flow state during serialization and lazy loading — for example to know the current db context when a summary reference lazy-loads its full document. That state is the execution context. It's wired automatically for web requests and Hangfire jobs; you only open one yourself for the few operations that require an ambient one (below).

What it is

public interface IExecutionContext
{
    IDictionary<object, object?>? Items { get; }
}

A per-flow key/value bag. Scrinium stores per-operation handlers in it: the current db context, the resolved source repository, active serializer modifiers, the ambient transaction session, the exclusive-access allowance, the dry run marker, and the resource lock leases of the flow. Enabling a serializer modifier or entering an exclusive-access flow throws ExecutionContextNotFoundException when Items is null (no ambient context) — when you must open one yourself is below.

One context, one flow. An execution context carries the ambient state of a single flow: the handlers a flow registers are the ones it resolves. Inside a web request the ambient state is the request's own — HttpContext.Items backs it on every branch — so it can't be isolated per parallel branch: open modifier, session or exclusive-access scopes only around sequential work there. On an async-local-backed flow (a background thread, a Hangfire job) give each parallel branch its own context with InitAsyncLocalContext() (below).

It's wired for you

AddScrinium registers an IExecutionContext that selects the right backing store: the current HttpContext.Items for web requests, and an async-local context otherwise. Hangfire jobs get their own async-local context from the filter AddScriniumWithHangfire installs. So inside a request or a Hangfire job, everything just works.

When you must open one yourself

Ordinary operations — CRUD, queries, lazy loads — never require a pre-opened context: each one self-creates an isolated context when none is ambient. You open one yourself only for the operations that must share the ambient context across calls:

  • enabling a serializer modifier (no-cache, reference readOnlyId), whose using scope must span the reads it modifies;
  • exclusive-access flows — RunWithExclusiveAccessAsync, and so seeding or migrations driven by your own code (SeedDbContexts() opens one per context for you);
  • keeping one ambient context across a whole unit of work instead of one per operation.

On a background thread with no ambient context — a hosted BackgroundService loop, a startup task — open an isolated one for the duration of the work:

using Etherna.Scrinium.Core.ExecContext.AsyncLocal;

using (AsyncLocalContext.Instance.InitAsyncLocalContext())
using (db.Engine.SerializerModifierAccessor.EnableCacheSerializerModifier(noCache: true))
{
    // the modifier requires the ambient execution context opened above
    var cats = await db.Cats.QueryElementsAsync(q => q.ToListAsync());
}

InitAsyncLocalContext() creates a new isolated context (even if one is inherited from an ancestor flow) and returns a handler; disposing it restores the previous context.

Pair it with a scope. A background service opens a DI scope per work cycle (for a fresh DbContext); add InitAsyncLocalContext() around the cycle when the work uses a serializer modifier or exclusive access — see Best practices and pitfalls.


Next: Best practices and pitfalls for the background-service pattern, or Querying for the no-cache modifier that relies on this context.

Clone this wiki locally