Async Operations & Locking
Since the graph database handles high-concurrency workloads, many operations are asynchronous to avoid blocking threads.
Async Enumeration
If you are processing a large result set, consider using AsEnumerableAsync(). This method parallelizes the reading of data from disk, which can significantly improve performance for heavy queries. AsEnumerableWithEdgesAsync() does the same but materializes each node's edges as well.
await foreach (var node in Graph.Query().StartAt(N.Log.Type).AsEnumerableAsync())
{
// Process one by one without buffering everything in memory
await ProcessLogAsync(node);
}
Cancelling enumeration
Both AsEnumerableAsync() and AsEnumerableWithEdgesAsync() accept a CancellationToken. The token also flows through automatically when you attach it with WithCancellation(token) on the await foreach, so the enumeration stops promptly once the token is cancelled:
await foreach (var node in Graph.Query()
.StartAt(N.Log.Type)
.AsEnumerableAsync()
.WithCancellation(cancellationToken))
{
await ProcessLogAsync(node);
}
Composing Async Query Pipelines
A few query entry points are asynchronous — StartSearchAsync(...), OutManyAsync(...), and StartAtSimilarTextAsync(...) return a Task<IQuery>, which forces an await mid-chain. The Then(...) extensions let you chain the remaining sync and async steps and await the pipeline once:
var hits = await Graph.Query()
.StartAtSimilarTextAsync("battery drains overnight", count: 20, nodeTypes: [N.SupportCase.Type])
.Then(q => q.IsRelatedTo(deviceUID)) // sync step
.Then(q => q.OutManyAsync(2, [N.Part.Type])) // async step
.Then(q => q.ToList()); // terminal projection
See Querying the Graph and the Graph Query Language reference for the full method list.
Code scope safety rules
Stored C# — an endpoint, an AI tool, a code index, a scheduled task, a migration script — runs inside an execution scope, and the scope owns the queries it hands out. A query holds a cancellation source, pooled score buffers and, once cached, a lease on the query cache's memory-mapped file; the scope releases all of that when the call ends. That is only correct while nothing the code started is still reading it, so the constructs that let work outlive the scope are rejected at compile time rather than left to fail later as a use-after-release.
The rules appear in the code editor as you type, and are enforced again when stored code is compiled. Each finding points at the exact offending call, and findings from nested lambdas are collapsed to one.
Existing code may need updating
Code written before these rules existed and using one of the patterns below no longer compiles and has to be rewritten before it runs.
| Rule | What it rejects | Instead |
|---|---|---|
MSK1001 |
Starting a thread: new Thread(...), Thread.Start(), ThreadPool.QueueUserWorkItem(...). |
Write async code. |
MSK1002 |
Blocking on a task: .Result, .Wait(), Task.WaitAll / WaitAny, .GetAwaiter().GetResult(), Thread.Sleep. |
await the task. |
MSK1003 |
Task.Run(...), Task.Factory.StartNew(...). |
RunTaskAsync(childScope => …) — see below. |
MSK1004 |
A recursive method or local function. | Rewrite as a loop with an explicit work list. Recursion here has no depth bound, so a query held in a frame can be kept alive indefinitely and a deep enough call takes down the host. |
MSK1005 |
A loop in a code index whose body never checks cancellation. | Call CancellationToken.ThrowIfCancellationRequested() in the body, or pass CancellationToken to something inside it. A nested loop is left alone when an enclosing one already checks. |
MSK1006 |
Anything else that can escape the call: Task.ContinueWith(...), new Timer(...), the synchronous Parallel.For / ForEach / Invoke, an async void method. |
Keep the work inside the call, or use RunTaskAsync. Parallel.ForEachAsync and await Task.WhenAll(...) are fine. |
MSK1007 |
Background work started with RunTaskAsync starting a query on the scope that started it. |
Start the query on the scope the lambda is handed, or DetachScope() the query. Reading the outer scope's other members is fine. |
A rule whose whole justification is a stranded query is not applied where no scope is in reach — inside a type the script declares, or in a static method — because none of what a closure could capture there is a scope query.
Running background work: RunTaskAsync
RunTaskAsync is what replaces Task.Run in stored code. It runs the lambda on the thread pool against a child scope of its own — its own Graph, Query() / Q(), CurrentUser, Logger and CancellationToken — and releases that scope's queries when the work finishes. That bounded lifetime is why background work is allowed at all.
// Endpoint: answer now, finish the bookkeeping afterwards.
var req = ParseBody<ReindexRequest>();
_ = RunTaskAsync(async task =>
{
// `task` is the background scope. Start queries on it, not on the endpoint's.
var stale = task.Query().StartAt(N.Document.Type).Where(N.Document.NeedsReindex, true).ToList();
foreach (var uid in stale)
{
task.CancellationToken.ThrowIfCancellationRequested();
await ReindexAsync(task, uid);
}
task.Logger.LogInformation("Re-queued {Count} documents", stale.Count);
});
return Ok(new { Queued = true });
The generic overload returns a result: await RunTaskAsync(async task => …). The result must not be one of the child scope's queries — it is released when the work finishes — so return the values read out of a query rather than the query itself.
It is available on the endpoint, shell, migration, index and search-index scopes as a top-level RunTaskAsync(...). In an AI tool it goes through the tool's scope: scope.RunTaskAsync(childScope => …).
You often don't need it
To run several calls in parallel and wait for them, no background scope is involved: call the async methods directly, collect the tasks in a list, and await Task.WhenAll(tasks) before the call returns. RunTaskAsync is for work that should keep going after the call has answered.
Letting one query outlive the scope: DetachScope()
IQuery.DetachScope() takes a single query back out of its scope's ownership, so the scope ending does not release it — and makes you responsible for disposing it:
// The reader below runs after this call has returned, so the query has to survive it.
var pending = Q().StartAt(N.Job.Type).Where(N.Job.State, "pending").DetachScope();
Reach for it only when a query genuinely has to outlive the scope — a result handed to something that reads it later — and dispose it when that reader is done. It is not available in the admin assistant's sandbox, where a detached query would leak the run's cancellation source and pooled buffers.
Locking and Thread Safety
When modifying the graph, you must use async methods to acquire locks. This ensures thread safety and prevents data corruption.
The Locking Pattern
To modify a node, you must follow this specific pattern:
- Acquire Lock: Use
TryGetLockedAsyncorGetOrAddLockedAsyncto get aLockedNode. - Modify: Perform your updates on the
LockedNodeobject. - Commit: Call
CommitAsyncto save changes and release the lock.
Important
Locks are exclusive. While you hold a lock on a node, no other thread can read or write to it. Keep your critical sections (the time between lock and commit) as short as possible.
// 1. Acquire Lock
var lockedNode = await Graph.TryGetLockedAsync(someUID);
if (lockedNode != null)
{
try
{
// 2. Modify
// Perform logic that might throw exceptions here
lockedNode.UpdateProperty("visited", true);
// 3. Commit
await Graph.CommitAsync(lockedNode);
}
catch (Exception)
{
// If something goes wrong, abandon changes to release the lock
Graph.AbandonChanges(lockedNode);
throw;
}
}
LockedNode vs ReadOnlyNode
- ReadOnlyNode: Lightweight, safe for reading. Returned by queries.
- LockedNode: Heavyweight, represents exclusive access to a node for modification.
CommitAsync
The CommitAsync method pushes changes to the storage engine and releases the lock.
// Commit single node
await Graph.CommitAsync(node);
// Commit multiple nodes (atomically)
await Graph.CommitAsync(nodeA, nodeB);
If you modify a node but decide not to save (e.g., validation failed), use AbandonChanges(node).