Use as a library
There are two ways to use Fallen-8. Most of this site describes the first: run it as a service and talk to it over REST. The second is to reference the engine as a .NET library and hold the graph inside your own process, with no HTTP, no serialization, and no server to operate. That is what this page covers.
The library is the same engine the service runs. Everything in graph model, path finding, subgraphs, indexes, analytics and save games is reachable in-process; the REST layer is a thin projection on top of it.
Install
Section titled “Install”The engine is published as the Fallen-8 package on
nuget.org and to GitHub Packages, from
.github/workflows/release.yml
on every v*.*.* tag:
dotnet add package Fallen-8dotnet add package Fallen-8It targets net10.0, is MIT-licensed, and takes four dependencies: Microsoft.Extensions.Logging,
Microsoft.Extensions.Caching.Memory, System.IO.Hashing and System.Numerics.Tensors. The root
namespace is NoSQL.GraphDB.Core; the types you touch most live in NoSQL.GraphDB.Core,
NoSQL.GraphDB.Core.Model and NoSQL.GraphDB.Core.Transaction.
The package declares Server GC with concurrent collection, because a resident graph is exactly the workload Server GC is built for. A non-web host would otherwise default to Workstation GC.
What is in the package, and what is not
Section titled “What is in the package, and what is not”| In the engine (this package) | Not in the engine |
|---|---|
| Graph model, transactions, indexes, path and subgraph algorithms, analytics | The REST API, its versioning, and the OpenAPI document |
| Persistence: checkpoints, the write-ahead log, save games | The API key and every other perimeter control (security) |
| Element embeddings and the vector index | The text-in embedding provider and the chat gateway, which are apiApp-only |
| The change feed | Document ingestion and the NLP sidecar |
Plugin discovery and the IPlugin contracts |
The Roslyn compilers behind dynamic fragments (see below) |
One graph per Fallen8 instance |
Namespaces, which are an apiApp-level collection |
Dynamic C# is a good illustration of the split. The engine declares the abstractions
ISubGraphRecipeCompiler, IStoredQueryCompiler and IPluginCompiler, and takes them as constructor
arguments; the Roslyn implementations live in the apiApp (Helper/PluginCompiler.cs and friends). A
library host that never needs compiled-at-runtime fragments passes nothing and simply calls the
strongly typed algorithms directly.
Because a Fallen8 object is one graph, the library equivalent of several namespaces is several
Fallen8 instances. The apiApp’s collection, its catalog and its /ns/{ns} routing are not part of the
package.
Your first graph
Section titled “Your first graph”The pattern is always the same: build a transaction, enqueue it, wait for it, then read.
using Microsoft.Extensions.Logging;using NoSQL.GraphDB.Core;using NoSQL.GraphDB.Core.Transaction;
var loggerFactory = LoggerFactory.Create(b => b.AddConsole());var fallen8 = new Fallen8(loggerFactory);var creationDate = Convert.ToUInt32(DateTimeOffset.Now.ToUnixTimeSeconds());
// A -> B -> C, created in one atomic transaction.var verticesTx = new CreateVerticesTransaction();verticesTx.AddVertex(creationDate, "node", new Dictionary<string, object> { { "name", "A" } });verticesTx.AddVertex(creationDate, "node", new Dictionary<string, object> { { "name", "B" } });verticesTx.AddVertex(creationDate, "node", new Dictionary<string, object> { { "name", "C" } });fallen8.EnqueueTransaction(verticesTx).WaitUntilFinished();
var vertices = verticesTx.GetCreatedVertices();
var edgesTx = new CreateEdgesTransaction();edgesTx.AddEdge(vertices[0].Id, "connects", vertices[1].Id, creationDate, "link");edgesTx.AddEdge(vertices[1].Id, "connects", vertices[2].Id, creationDate, "link");fallen8.EnqueueTransaction(edgesTx).WaitUntilFinished();
Console.WriteLine($"{fallen8.VertexCount} vertices, {fallen8.EdgeCount} edges");AddEdge takes the edge type ("connects") before the target and the label ("link") after
the creation date. They are different things and the argument order does not warn you:
edge type vs label.
Note that the created vertices come back from the transaction object
(verticesTx.GetCreatedVertices()), not from the engine. The transaction is the record of what it did.
Reading
Section titled “Reading”Reads go straight at the engine through IFallen8Read. They need no transaction and no lock: the
graph is published copy-on-write, so a reader always sees a consistent snapshot.
| Member | Returns |
|---|---|
VertexCount, EdgeCount |
Live element counts |
TryGetVertex, TryGetEdge, TryGetGraphElement |
One element by id, false when absent or removed |
GetAllVertices, GetAllEdges, GetAllGraphElements |
Every element, optionally filtered to one label |
GraphScan |
Elements whose named property satisfies a typed comparison |
GraphScanAllProperties |
Elements where any property value contains a search term |
IndexScan, RangeIndexScan |
Point and range lookups against a named index |
FulltextIndexScan, VectorIndexScan |
Fulltext hits with scores, and nearest-neighbour vector hits |
These follow the repository-wide Try*(out result, ...) : bool convention: an absent element or an
unknown index is false, not an exception.
Writing
Section titled “Writing”Every mutation is a transaction. EnqueueTransaction hands it to the single writer and returns a
TransactionInformation; WaitUntilFinished() blocks until it commits or rolls back, and
GetTransactionState(txId) polls instead.
The built-in transactions live in NoSQL.GraphDB.Core.Transaction:
| Purpose | Transactions |
|---|---|
| Create | CreateVertexTransaction, CreateVerticesTransaction, CreateEdgeTransaction, CreateEdgesTransaction |
| Properties | AddPropertyTransaction, AddPropertiesTransaction, RemovePropertyTransaction |
| Remove | RemoveGraphElementTransaction, RemoveGraphElementsTransaction, TabulaRasaTransaction, TrimTransaction |
| Subgraphs | CreateSubGraphTransaction, RemoveSubGraphTransaction |
| Registration | RegisterPluginTransaction, RemovePluginTransaction, RegisterStoredQueryTransaction, RemoveStoredQueryTransaction |
| Embeddings | SetEmbeddingsTransaction |
| Persistence | SaveTransaction, LoadTransaction |
| Your own | DelegateTransaction (below) |
A batch transaction is all or nothing: CreateVerticesTransaction with 10,000 vertices either
commits every one or leaves the graph untouched. On a rollback the engine records why, as
TransactionFailureReason:
| Reason | Means |
|---|---|
None |
The default, and the value on a committed transaction |
InvalidInput |
Structurally invalid, for example a malformed subgraph pattern |
NotFound |
A referenced element did not exist, for example a missing edge endpoint |
Conflict |
Conflicts with current state, for example a name already in use |
QuotaExceeded |
A count or materialized-element ceiling was hit |
InternalError |
An unexpected fault, including an exception that escaped execution |
The REST layer maps these onto 400, 404, 409 and 500; in-process you read the reason directly.
Terminal transaction state is retained for the most recent 100,000 transactions. Past that, the
oldest ids are dropped and GetTransactionState reports them as not existing. Long-lived hosts should
therefore not treat a transaction id as a permanent handle.
Multi-step writes with DelegateTransaction
Section titled “Multi-step writes with DelegateTransaction”When a write needs logic that no built-in transaction expresses, DelegateTransaction runs your body on
the writer thread against an IFallen8WriterContext. That context is the sanctioned mutation surface:
CreateVertex, CreateVertices, TryCreateEdge, SetProperty and RemoveProperty, all reversible if
the body throws.
One asymmetry matters. A removal performed inside the body is not undone by a rollback, because the soft-delete model has no generic un-remove. A body that only creates and sets properties is fully reversible; a body that also removes elements has a weaker guarantee. Split the removal into its own transaction when that matters.
Single-threaded hosts, such as browser WebAssembly
Section titled “Single-threaded hosts, such as browser WebAssembly”By default the engine owns a dedicated writer thread: enqueuing hands the transaction over, and the
writer drains whatever is ready into one commit group with one fsync. Some hosts cannot start a thread at
all. A single-threaded WebAssembly runtime in the browser is the usual case, where Thread.Start throws
PlatformNotSupportedException. There, the engine applies each transaction inline on the calling
thread instead:
var fallen8 = new Fallen8(loggerFactory); // no special argument needed
// In a single-threaded host this reports Inline; on a server, Threaded.Console.WriteLine(fallen8.TransactionExecution);
var info = fallen8.EnqueueTransaction(tx);// Already terminal: the write happened inside the call above.Assert(info.Completion.IsCompleted && info.TransactionState == TransactionState.Finished);The mode is detected at runtime, so the same package works in both places with no compile-time switch and
no host configuration. TransactionExecutionMode states it explicitly when you would rather assert than
detect: Threaded fails loudly on a host that cannot start the writer, Inline forces inline execution
anywhere.
| In inline mode | Behaviour |
|---|---|
EnqueueTransaction |
Applies, flushes and completes the transaction before returning |
WaitUntilFinished(), await Completion |
Return immediately; there is nothing left to wait for |
Ordering, rollback, GetTransactionState |
Unchanged; the same execution path runs |
| Write-ahead log, change feed | Both work; each transaction is its own commit group, so one fsync each |
| Concurrent callers | Serialized, so the single-writer invariant holds even if the host has threads |
| Group commit | Given up; it was the only thing the queue was buying |
To put a face on an in-browser graph, Studio’s canvas component renders it straight from your interop layer; the walk-through from a WASM engine to the canvas to the full embedded Studio is Embed scenarios.
Checkpoints work here too. A save fans its sidecar writes out across the thread pool and then waits
for them, and a load reads its partitions in parallel, neither of which a single-threaded host can do,
because the one thread is the one waiting. Both now run inline instead, chosen by the same runtime probe,
so SaveTransaction and LoadTransaction complete on a browser host as well (verified writing into the
Emscripten virtual filesystem and reading the graph back). Getting those bytes out of the virtual
filesystem and into somewhere durable (IndexedDB, a download, your own API) is the host’s job, not the
engine’s.
One thing the host has to do for itself. Plugin lookup by name finds its type by enumerating the assemblies in the application’s base directory, and a browser has none there, so every name is a clean miss - which used to mean no index and no vector search in a browser at all. Register the types you need and the names resolve again: see Registering plugin types when discovery cannot help below.
Registering plugin types when discovery cannot help
Section titled “Registering plugin types when discovery cannot help”Indexes, algorithms, graph functions and services are all plugins, and the engine normally finds one by
its PluginName while enumerating the *.dll files under AppContext.BaseDirectory. Two hosts get
nothing out of that. A browser-wasm host has no dll files there at all, because the assemblies are
packaged as WebCIL inside the app bundle. A trimmed host may no longer contain a type that exists
only as a string. Both fail the same clean way: the name is not found.
RegisterPluginType<T> closes that. The host hands the engine the types it wants reachable by name, and
resolution consults this engine’s registry before it tries discovery:
using NoSQL.GraphDB.Core.Index;using NoSQL.GraphDB.Core.Index.Vector;
var fallen8 = new Fallen8(loggerFactory);
fallen8.RegisterPluginType<DictionaryIndex>();fallen8.RegisterPluginType<VectorIndex>();
// The plugin name now resolves out of the registry, with nothing scanned.fallen8.IndexFactory.TryCreateIndex(out var vectors, "embeddings", "VectorIndex", new Dictionary<string, object> { { "dimension", 3 } });T is a type argument, not a string, so it travels straight to the Activator call: nothing is
scanned, nothing is compiled, and the member carries no trim warning. What the contract requires:
| Rule | Detail |
|---|---|
| Exactly one contract | T implements one of IShortestPathAlgorithm, ISubGraphAlgorithm, IGraphAnalyticsAlgorithm, IGraphFunction, IIndex, IService. Zero or two: InvalidInput |
| A public parameterless constructor | Required by the new() constraint, so a type the engine could not activate is a compile error at your call site |
| The name comes from the instance | Registration probes one instance for its PluginName and Description. There is no name parameter, so nothing can disagree with the type |
| It is a transaction | Returns TransactionInformation; an invalid name is InvalidInput, a duplicate Conflict, the registration ceiling QuotaExceeded |
| Registry first | A registered type shadows a same-named discovered one, which is the point in a host where the built-in cannot be discovered |
| Per engine | The registry belongs to one Fallen8 instance, like stored queries. A host with several graphs registers per instance |
A host registration is never persisted, deliberately. There is no source to store, so neither a
checkpoint nor the write-ahead log carries one, and the commit reports Durable true because there is
nothing to write. The consequence is a rule for the host: register your types on every start, and do it
before a LoadTransaction, because a load rehydrates each saved index by its plugin name. Registrations
already in place survive a load rather than being replaced by it, so the order is register, then load.
These claims are gated rather than asserted. The repository carries a trimmed browser-wasm probe,
tools/browser-probe, which runs
headless under node in CI and fails the build if a thread can start on that runtime, if index creation
succeeds without a registration or fails with one, if the vector search misses, if a checkpoint round trip
loses a host-registered index or its content, or if a load wipes a registration.
Durability and options
Section titled “Durability and options”The constructor you pick decides what the instance carries:
| Constructor | Gives you |
|---|---|
new Fallen8(loggerFactory) |
A pure in-memory graph: no WAL, no change feed, nothing on disk |
new Fallen8(path, loggerFactory) |
An instance loaded from an existing checkpoint at path |
new Fallen8(loggerFactory, changeFeedOptions) |
The change feed as an in-order event stream |
new Fallen8(loggerFactory, writeAheadLogOptions, ...) |
A write-ahead log plus the optional compilers |
The widest overload also takes ISubGraphRecipeCompiler, IStoredQueryCompiler, IPluginCompiler, a
ChangeFeedOptions, a metrics scope id and a TransactionExecutionMode, so a host can opt into exactly
the capabilities it wants.
Snapshots are written and read with SaveTransaction and LoadTransaction, in the same on-disk format
the service uses, which means a graph saved in-process can be loaded by the service and the other way
round.
Publishing trimmed
Section titled “Publishing trimmed”The package is marked trim-compatible, so a consumer can publish with PublishTrimmed=true (even
TrimMode=full) and needs no TrimmerRootAssembly entry for it. Measured once on a browser-wasm
consumer, that took the engine assembly from 412 KB to 166 KB and the whole managed payload from
3.19 MB to 2.02 MB - a reading of one build at one point in time, not a promise about yours.
What survives trimming, and what the compiler will warn you about:
| Trim-safe | Warns at your call site (IL2026) |
|---|---|
| Constructing an in-memory engine, the mutation transactions, every read and scan | Naming a path or analytics algorithm by string: TryCalculateShortestPath(out paths, "BLS", …), TryRunAnalytics |
TryCalculateShortestPath<T> - the algorithm as a type argument |
CreateSubGraphTransaction (its algorithm is a plugin name) |
SubGraphFactory.TryCreateSubGraph<T> |
Asking what discovery can see: GetAvailableIndexPlugins, GetAvailableSubGraphPlugins, GetAvailableServicePlugins |
RegisterPluginType<T>, and creating an index or a service by name once its type is registered |
SaveTransaction / LoadTransaction, and DelegateJson |
| Indexes and vector search you have already created, and the change feed | new Fallen8(path, loggerFactory) and the write-ahead-log constructor overload |
The rule behind the table: a type the engine reflectively constructs from a type argument can be
kept by the trimmer, so those paths are annotated and safe. A type that exists only as a string -
resolved by scanning assemblies, or read out of a checkpoint - cannot be, so those members declare
[RequiresUnreferencedCode] and tell you at build time rather than failing at runtime. Index and service
creation are named by string yet do not warn, because they resolve
a registered type first, which involves no
reflection over strings at all; their discovery fallback sits behind one narrow suppression rather than a
warning handed to every caller. If you trim and
call one anyway, the two halves of that column fail differently. A lookup by name degrades quietly:
discovery finds nothing, so the call returns false. A checkpoint or delegate path fails loudly,
because a payload-named type that no longer resolves is corrupt data rather than a miss: the checkpoint
reader resolves types with throwOnError: true, so LoadTransaction rolls back with InternalError and
the exception on TransactionInformation.Error; DelegateJson deserialization throws; and a spatial
index whose metric type cannot be resolved raises InvalidDataException, which the load logs and skips
so the rest of the checkpoint still arrives.
A browser build sharpens the column. There, discovery finds no assemblies at all, so every lookup that
resolves a name by scanning is a guaranteed miss whether you trim or not, and the answer is to register the
types instead. The checkpoint entry is the one that stays: SaveTransaction and LoadTransaction still
carry the requirement, because a checkpoint stores property values it resolves reflectively on load, so a
browser host that saves and loads suppresses IL2026 at its own call site - which is exactly what the
committed probe does.
The public surface is versioned
Section titled “The public surface is versioned”The package version is not decorative: it tracks breaking changes to the engine’s public types, which
are recorded in
fallen-8-core.csproj.
The REST contract was unchanged by all of them.
| Version | Breaking change |
|---|---|
0.1.0 |
VertexModel.OutEdges/InEdges became read-only views, and TryGetOutEdge/TryGetInEdge return IReadOnlyList<EdgeModel> instead of immutable collections |
0.2.0 |
GetAllVertices/GetAllEdges/GetAllGraphElements and the index scans return IReadOnlyList<T> instead of ImmutableList<T>, dropping the per-call AVL tree |
0.3.0 |
IIndex gained a required Boolean SupportsPointEqualityLookup; every built-in index implements it, an external implementation must add it |
In each case the common members (.Count, the indexer, foreach, LINQ) were unaffected: only members
specific to the immutable collections were lost.
See also
Section titled “See also”- Graph model: the vertex, edge, property and label semantics the API exposes
- Capacity and performance: what a graph costs in memory and how fast writes go
- Plugin registration: the
IPlugincontracts you implement in-process - Save games: the checkpoint format, the WAL, and what survives a crash
- Architecture: where the engine sits relative to the REST API and the UI