Durable embedded apps¶
This guide covers running KGLite as the embedded database behind an application — the open → mutate → reopen lifecycle, persistence on close, and crash-safe durable writes via a write-ahead log (WAL).
If you only build a graph, query it, and throw it away, you don’t need any of
this — KnowledgeGraph() plus Data Loading is enough. Reach for this
guide when the graph is long-lived state your app reopens across runs:
an agent’s memory, a knowledge base that accretes facts, a service that
accepts writes between restarts.
The lifecycle entry points¶
Call |
What it does |
|---|---|
|
Load-or-create. Opens the graph at |
|
Load an existing |
|
Write a full checkpoint, atomically and durably. With no |
|
Serialize/deserialize the graph to/from a |
|
Persist to the remembered path. The graph stays usable afterwards. |
|
Auto-saves on clean block exit; skips the save if the block raises, preserving the last good file. |
Every save() is atomic and torn-proof, even in non-durable mode: it writes
to a sibling temp file and atomically renames it over the target, so a crash
mid-save can never leave a half-written .kgl — a reader always sees the old
file or the complete new one. With fsync=True (default) the file and its
directory are flushed to physical storage before returning; pass fsync=False
to skip that flush for speed in a hot loop (still atomic). This removes the
temp-file + os.replace + dir-fsync dance consumers used to hand-roll.
Corrupt-file detection is typed. load() / from_bytes() raise
kglite.FileFormatError (a subclass of kglite.KgError) on a corrupt,
truncated, or wrong-format input, and kglite.FileError on a missing file — so
a disposable-cache consumer can branch “corrupt → rebuild from source” vs
“missing → create new” cleanly, without a broad except IOError.
Detection does not rely on the damage happening to be structurally invalid.
Every section of a .kgl — topology, each node type’s columns, embeddings,
time series, secondary labels, vector index — is written with a checksum and
verified on load, so a bit that flips in storage or transit produces an error
naming the damaged section rather than a graph that loads with quietly
different data. Files written by older versions lack the checksums and load
unchanged; files written by this version load on older versions.
The thread that ties these together is the remembered path: open() and
load() record where the graph came from, so a later bare save() — or the
context manager’s auto-save — writes back without you re-specifying the target.
import kglite
# First run: file doesn't exist → fresh graph, bound to "app.kgl".
with kglite.open("app.kgl") as g:
g.cypher("CREATE (:Person {id: 1, name: 'Alice'})")
# clean exit → auto-saved to app.kgl
# Next run: file exists → loaded back.
with kglite.open("app.kgl") as g:
g.cypher("CREATE (:Person {id: 2, name: 'Bob'})")
print(g.cypher("MATCH (p:Person) RETURN count(p) AS n").scalar()) # 2
The default: crash-safe¶
open() is durable by default. Every committed mutation is fsync’d before the
call returns, so a mutation that has returned survives a hard crash.
g = kglite.open("app.kgl")
g.cypher("CREATE (:Order {id: 1001, total: 49.90})") # fsync'd before this returns
You get this without asking for it because it is what makes an embedded database
trustworthy: the alternative default silently loses every write since the last
explicit save() whenever a process dies.
The default is the strongest level, not the only one. If power-loss safety is
more than your application needs, durable="normal" keeps the log and drops
only the per-commit barrier — see Choosing a durability
level.
Choosing a durability level¶
durable names what a committed mutation survives. It uses SQLite’s
synchronous vocabulary, and the levels are stated as guarantees rather than
as syscalls — the syscall differs by platform, the guarantee does not.
|
A committed mutation survives… |
Per-commit cost |
|---|---|---|
|
process crash, OS crash, power loss |
one barrier |
|
process crash — |
no barrier |
|
nothing since the last |
no log |
True and False are accepted spellings of "full" and "off", so existing
code keeps working unchanged.
"normal" — the process-crash level¶
g = kglite.open("app.kgl", durable="normal")
g.cypher("CREATE (:Order {id: 1001, total: 49.90})") # logged, not barriered
The frame is handed to the kernel with a plain write before the call returns.
The page cache belongs to the kernel, not to your process, so the commit
survives your process dying by any means — an uncaught exception, kill -9,
the OOM killer. What it does not survive is the kernel dying: an OS crash or
a power cut loses commits made since the last save().
That is the right trade for most applications, because a crashing process is
the failure that actually happens and a power cut is the one you keep backups
for. It is also the level to reach for when per-commit barrier latency is
shaping your write throughput — "normal" writes the same log frame and skips
only the barrier.
"off" — no log at all¶
When the graph is rebuildable from source data — a bulk load, a derived index, a scratch analysis — logging buys nothing:
g = kglite.open("kb.kgl", durable="off")
g.add_nodes(df, node_type="Topic", unique_id_field="id") # nothing logged
g.save() # one explicit checkpoint at the end
g.close()
What that is not: crash-safe. A snapshot is written only when you call
save()/close() or the context manager exits cleanly. If the process is
killed mid-session (kill -9, power loss, an unhandled crash before the next
save()), the work since the last checkpoint is gone.
Taking a power-safe point on demand: sync()¶
"normal" skips the per-commit barrier — but you can take that barrier
whenever it matters:
g = kglite.open("app.kgl", durable="normal")
def handle_request(payload):
g.cypher("CREATE (:Event $props)", params={"props": payload})
handle_request(...)
g.sync() # everything committed so far now survives power loss too
sync() writes no checkpoint and truncates nothing — it only makes the
existing log durable, which is why it is the right granularity for “flush at
the end of a request” or “flush before shutdown”. A full save() republishes
the entire graph and is far more expensive.
Under "full" it returns immediately (every commit was already barriered). On
a graph with no log it raises ValueError rather than silently doing nothing,
because a caller who believes they bought power-safety and got nothing is the
failure that costs data.
How durability works¶
With durability on, every committed mutation is appended to a
<path>-wal sidecar file and fsync’d to stable storage before the call
returns. A mutation that has returned is guaranteed to survive a hard crash.
How it fits together:
Each mutation → one WAL frame, written before the call returns. Under
"full"the frame is also barriered to stable storage per commit; that barrier is the durability cost, and it bounds write latency by device latency rather than engine speed (see “Cost and tuning” below). Under"normal"the frame is written but not barriered.save()→ writes a full checkpoint (.kgl) and truncates the WAL. The checkpoint is the new baseline; the WAL starts empty again.open(...)→ loads the last checkpoint, then replays any WAL frames written since it, reconstructing the exact committed state — including work that was never checkpointed because the process crashed.
So the on-disk state is always “last checkpoint + replayable tail”, and reopen folds the two back together automatically.
Crash recovery in practice¶
import os
# Process A — commits, then dies hard before any save().
g = kglite.open("app.kgl", durable=True)
g.cypher("CREATE (:Person {id: 1, name: 'Alice'})") # committed + fsync'd
g.cypher("CREATE (:Person {id: 2, name: 'Bob'})") # committed + fsync'd
os._exit(1) # hard crash — no save(), no clean close
# Process B — reopen recovers both, from the WAL.
g = kglite.open("app.kgl", durable=True)
assert g.cypher("MATCH (p:Person) RETURN count(p) AS n").scalar() == 2
g.save() # checkpoint: fold the WAL into a fresh .kgl, truncate the log
Both rows survive the crash even though save() was never called in process A —
they were fsync’d to the WAL at commit time, and reopen replayed them.
Choosing the mode¶
KGLite has three persistence postures for an embedded app. Pick by what you’re optimising for:
You want… |
Use |
Trade-off |
|---|---|---|
Every committed write to survive a hard crash |
|
One barrier per commit; reopen is O(graph) (loads the whole graph). |
Committed writes to survive a crashing process, cheaply |
|
No barrier per commit; an OS crash or power cut loses work since the last |
Maximum write throughput on rebuildable data |
|
Nothing logged; a crash loses work since the last checkpoint. |
Crash safety on a graph that outgrows RAM |
|
Same per-commit WAL guarantee; property columns spill to mmap. The |
100 M+ nodes (Wikidata-scale), cheap cold-open |
|
Paged mmap, lazy load; no per-commit WAL — durability is your |
The first three are in-memory — the whole graph lives in RAM, which is what makes traversal and multi-hop queries fast. Durability adds crash-safety on top of that model without changing the in-memory read path.
If your app is simply growing, reach for mapped, not disk. mapped
is the larger-than-RAM mode that keeps this guide’s guarantee: it is durable by
default and its crash recovery is kill-9 tested alongside in-memory. disk is
a different trade — a Wikidata-scale exploration mode whose commit boundary is
an explicit save(), not a logged write (see the Limitations below). Choosing
disk because a graph got big means giving up per-commit crash safety you
did not have to give up.
If cold-open latency is what hurts, the storage mode is the lever — not the
log. Reopening a .kgl decodes its whole payload before the first query
answers, and that cost scales with the file. It is one serialized payload
rather than an addressable layout, so there is nothing to defer; and a mapped
.kgl is read back by deserializing into a memory backend and then swapping it
onto the mapped one, which is why a mapped reopen costs what a memory reopen
costs. storage="disk" is the only mode that changes that shape: a disk
directory is already in its query-ready layout, so opening it maps files
instead of decoding a payload — measured 6.5x faster to reopen at ~400k
edges, and an external evaluation measured ~28x at 10.5M, so treat the small
number as the floor rather than the rate. The price is the one above: no
per-commit WAL, durability is your save() calls. Keeping the log and
checkpointing regularly bounds replay, which is a different cost from
decoding the checkpoint — see Cost and tuning.
Serving concurrent reads¶
A KnowledgeGraph is single-owner — don’t share one instance across threads
while a thread mutates it (that raises a clear RuntimeError). For a read-heavy
server, take an immutable snapshot with g.freeze() → a FrozenGraph that
shares the data via an O(1) clone and serves cypher() from many threads at
once, lock-free. When the data changes, build/reload, freeze() again, and swap
the snapshot in. See Concurrency for the full model.
snapshot = g.freeze()
# hand `snapshot` to N reader threads — concurrent, lock-free
snapshot.cypher("MATCH (o:Order) RETURN count(o)")
Durability and shared concurrent writes don’t combine in one handle. A
Session (graph.session() / kglite.open_session(...)) serves shared reads
and serialized writes, but its execute() writes land on a working copy visible
only through that session — reachable by neither the log nor the owning graph’s
save(). A Session write against a durable graph therefore raises, rather
than applying a mutation nothing can persist; reads are unaffected. For a
durable app, keep writes on the durable KnowledgeGraph itself (there they are
serialized and fsync’d) and use freeze() snapshots for concurrent reads.
Reach for Session writes with durable=False, when you need shared concurrent
writes but not durability. See Concurrency for the full
model.
Cost and tuning¶
"full"is barrier-bound, not engine-bound. A workload of many small committed transactions spends its time waiting on the disk to confirm each barrier, not in KGLite. This is the price of power-loss safety and is inherent to any WAL database. The cost scales with the number of commits and with device latency, not with graph size, and reads pay nothing at all."normal"is the level to try before you reach for"off". It writes the same log frame and skips only the barrier, so it costs roughly what an unlogged write costs while still losing nothing to a crashing process. If you were about to disable durability purely for write throughput, this is almost always the better answer — andsync()gives you power-safe points wherever you actually need them.On macOS,
"full"buys more than SQLite’s default does. KGLite’s barrier isF_FULLFSYNC, which flushes the drive’s own write cache; SQLite’s defaultsynchronous=FULLissues a plainfsync, which on macOS does not. The guarantees are therefore not the same thing measured differently — KGLite’s default is the stronger one, and it costs accordingly.Batch where you can. One
cypher()that creates 1,000 nodes is onefsync; 1,000 separatecypher()calls are 1,000fsyncs. Group related mutations into a single statement (or a transaction — see Transactions and sessions) when they logically commit together.Checkpoint to bound recovery time. Reopen replays every WAL frame since the last
save(). Replay is fast (frames are folded into net per-entity state and the index rebuilt once), but a periodicsave()keeps the WAL short and recovery near-instant for write-heavy, rarely-restarted services.
Limitations¶
Not available for
storage="disk". A disk graph commits by publishing an immutable generation, so its durability boundary is that publish rather than a logical log; reconciling a replayed frame against a published generation needs a generation-aware log this release does not have.open(path, storage="disk")therefore opens non-durable, and bothdurable="full"anddurable="normal"raiseValueErrorrather than pretending — the levels are not uniform across storage modes, because the blocker here is the commit boundary itself and not barrier strength.storage="disk"supports onlydurable="off". The in-memory default andstorage="mapped"support every level — if you want crash safety on a graph that outgrew RAM,mappedis the answer.What disk mode does guarantee is worth stating exactly, because it is stronger than “no crash safety” and is kill-9 tested (
crates/kglite/tests/disk_crash_guarantee.rs):A crash loses exactly the mutations made since the last
save(), and nothing else. The graph reopens at the last published generation, complete and uncorrupted — never at a partially-written one.Between
save()calls, disk-mode mutations live only in the process’s heap overlay; nothing is written, so nothing can be half-written. The publish itself is crash-atomic: the staged snapshot isfsync’d, renamed into place, and only then does an atomically-replacedCURRENTpointer select it, so a crash mid-publish leaves the previous generation selected. No acknowledged commit is ever lost — the acknowledgement point is yoursave()call.Budget for the checkpoint’s cost before you sprinkle
save()calls. Every disksave()writes a complete new generation and the superseded ones are retained, so N checkpoints leave N full copies of the graph on disk. That is deliberate — readers hold their generation mmap’d and must not have it deleted underneath them — but there is no retention policy yet, so checkpoint on a schedule you have the disk budget for, and prune oldgenerations/gen_*directories yourself once no reader is using them.Some state is checkpoint-only. The log describes nodes, edges, and labels. Schema and config metadata, user-created indexes, embeddings, and timeseries have no log entry, so they are persisted by
save()rather than recovered by replay. Callsave()after changing them if a crash must not lose them.A
withblock is not a transaction. Each mutation commits as it runs, so an exception inside the block does not undo mutations that already returned — they are recovered on the nextopen(). Usebegin()when you want discard-on-error (Transactions and sessions).
See also¶
Transactions and sessions —
begin()/commit()/rollback(), snapshot isolation, and how the Bolt server consumes the same surface.Core Concepts — the memory / mapped / disk storage modes.
Data Loading — bulk-loading the seed data an app starts from.