kglite::api::session — the binding-shared core¶
This document is written for binding implementers: anyone wiring kglite into a new language runtime (Go via cgo, TypeScript via napi, Java via JNI, …) or a new transport (a custom RPC layer, an embedded HTTP service, etc.). If you only use kglite from Python or through the bundled Bolt / MCP servers, you don’t need to read this — those bindings already wrap the session module for you.
What the module is¶
kglite::api::session is the single source of truth for the
canonical Cypher pipeline and the snapshot/working CoW transaction
mechanics. Every kglite binding (the PyO3 surface, the
kglite-bolt-server, the kglite-mcp-server) wraps the same two
free functions and the same two types:
// Free functions — the pipeline orchestration:
pub fn execute_read(graph: &DirGraph, query: &str, opts: &ExecuteOptions) -> Result<ExecuteOutcome, KgError>;
pub fn execute_mut (graph: &mut DirGraph, query: &str, opts: &ExecuteOptions) -> Result<ExecuteOutcome, KgError>;
// Types — the transaction state machine:
pub struct Session { … } // shared Arc<DirGraph> with commit-swap semantics
pub struct Transaction { … } // snapshot + lazy working CoW + base_version
pub enum CommitOutcome { NoWritesNoOp, Committed{…}, ConflictDetected{…} }
It is pure Rust — no PyO3, no async runtime, no transport. The
binding chooses its own concurrency (Python’s GIL, bolt-server’s
per-tx Mutex, your hypothetical Go binding’s sync.Mutex), its
own error mapping (PyErr, BoltError::Query, a Go error), and its
own value serialization (PreProcessedValue, BoltValue, MCP
JSON). The session module just runs the same code path beneath
them.
Why it exists¶
Prior to Phase E, the same Cypher pipeline orchestration lived in three places (pyapi, mcp-server, bolt-server) and the CoW transaction code in two (pyapi, bolt-server). This duplication silently cost correctness twice in a single sprint:
validate_schemawas present in pyapi but missing from mcp-server and bolt-server — a query referencing an unknown label would error in Python but succeed-with-empty-result in Bolt and MCP.mark_lazy_eligibilitywas wrongly included in bolt-server, producing 0-rowRETURN xresponses for any non-ORDER BYquery (the lazy descriptor was populated butresult.rowswas empty).
A Go binding or a future async TypeScript binding would have multiplied the drift. Phase E extracts the pipeline once, so future bindings cannot accidentally diverge.
Surface area¶
Session¶
let session = Session::new(DirGraph::new());
let v0 = session.version(); // OCC base
let snap = session.snapshot(); // cheap Arc clone; readers hold this
let tx = session.begin(); // read-write
let read_tx = session.begin_read(); // read-only (working_mut rejected)
The outer Mutex<Arc<DirGraph>> is brief-acquire only. A
snapshot() call grabs the lock just long enough to Arc::clone
the inner — readers then hold a stable view via their Arc handle,
unaffected by subsequent commits.
Transaction¶
let mut tx = session.begin();
let g_pre = tx.current().unwrap(); // &DirGraph (the snapshot view)
// First mutation materializes the working copy. Either Arc::try_unwrap
// (free — tx held the sole Arc ref) or a deep clone fallback.
let g_post = tx.working_mut()?; // &mut DirGraph
// ... mutating Cypher executes against g_post ...
// Subsequent reads route through working automatically:
let _ = tx.current(); // now &working, not &snapshot
assert_eq!(tx.has_writes(), true);
Read-only transactions reject working_mut with
KgError::Argument. Bindings surface the rejection as their typed
“read-only operation” error.
Session::commit¶
match session.commit(tx, /* check_occ = */ true) {
CommitOutcome::NoWritesNoOp => { /* nothing to do */ }
CommitOutcome::Committed { new_version } => { /* readers see new graph on next snapshot() */ }
CommitOutcome::ConflictDetected { current_version, base_version } => {
// Another writer committed between this tx's begin() and commit().
// Working copy is dropped; binding surfaces a typed conflict error.
}
}
OCC is opt-in per call. Pass true to enforce the version
check; pass false for last-writer-wins semantics (some test
fixtures use this — production bindings should always pass true).
Bolt-server passes true.
ExecuteOptions¶
let opts = ExecuteOptions {
params: ¶ms, // borrowed &HashMap<String, Value>
deadline: Some(Instant::now() + Duration::from_secs(30)),
max_rows: Some(10_000),
lazy_eligible: false, // true only if the binding has a lazy materializer (pyapi's ResultView does; bolt-server doesn't)
disabled_passes: None, // or Some(set) for user-toggle
embedder: None, // Arc<dyn Embedder> required for text_score()
};
lazy_eligible matters: when true, the executor returns a
CypherResult whose lazy field may be Some(LazyResultDescriptor),
and the binding must materialize it row-by-row on demand. Bindings
that don’t have a lazy materializer must pass false — otherwise
they’ll see empty result.rows for any RETURN x query without
ORDER BY. (This was the bolt-server bug fixed in C.6.)
Snapshot isolation guarantees¶
Scenario |
Visibility |
|---|---|
Reader inside tx_a sees its own pending writes |
✅ — |
Outside reader (auto-commit) sees tx_a’s pending writes |
❌ — they hold a snapshot Arc that doesn’t include the in-flight |
Reader holding |
❌ — they see the pre-commit graph; their Arc still points at the old inner |
Reader who calls |
✅ — fresh Arc clones the post-commit graph |
tx_b’s reads after tx_a commits |
tx_b’s snapshot is fixed at tx_b’s begin(); doesn’t refresh mid-transaction |
The snapshot semantics are MVCC-style: each transaction sees a stable view of the graph from its begin() moment, and commits are atomic Arc swaps.
Concurrency models¶
The session module is Send + Sync. Bindings layer their own
concurrency over it:
Python (pyapi): a single
KnowledgeGraphwraps anArc<DirGraph>directly. The GIL serializes pyapi calls; reads release the GIL viapy.detach()for parallel readers. Sessions are implicit (one perKnowledgeGraph).bolt-server: an
Arc<Session>shared across all connected Bolt clients. Each Bolt session owns aMutex<TxState>; the outer Mutex is per-tx, not per-server, so concurrent writers in different Bolt sessions don’t block each other on the open-tx path. They only contend on the briefcommit-swapinSession::commit. OCC turns conflicting commits intoClientError("Transaction conflict").mcp-server: stdio MCP is single-threaded per connection; no Mutex needed beyond what
Sessionprovides internally.
A new Go binding would typically use sync.Mutex around a
*Session handle; a new napi binding the same in async JS. The
session module imposes no preference.
Sketch: wrapping from a new binding¶
A minimal wrapper looks like this (Go/cgo flavor; sketch only):
// kglite-go: pseudo-cgo bindings to kglite::api::session
type Session struct { handle *C.KgliteSession }
type Tx struct { handle *C.KgliteTransaction }
func (s *Session) Run(query string, params map[string]any) (Rows, error) {
opts := buildExecuteOptions(params)
res := C.kglite_session_execute_read(s.handle, cstr(query), opts)
if res.err != nil {
return nil, mapError(res.err)
}
return rowsFromOutcome(res), nil
}
func (s *Session) Begin() *Tx { return &Tx{handle: C.kglite_session_begin(s.handle)} }
func (s *Session) Commit(tx *Tx) error {
out := C.kglite_session_commit(s.handle, tx.handle, /*check_occ=*/1)
switch out.kind {
case C.CommitOutcomeKind_NoWritesNoOp: return nil
case C.CommitOutcomeKind_Committed: return nil
case C.CommitOutcomeKind_ConflictDetected: return ErrConflict
}
}
The hard part — the pipeline + CoW + OCC — is shared via the C ABI exposure of session::*. Each binding only owns the marshalling layer.
What’s NOT in this module¶
Out of scope |
Why |
|---|---|
Async / Future / Tokio |
The binding chooses. bolt-server uses tokio; pyapi is sync. The session pipeline itself is synchronous. |
Lazy materializer |
Lives in pyapi ( |
Streaming PULL n |
Bolt’s record-by-record PULL is implemented in bolt-server’s |
Routing / clustering / TLS |
Transport concerns owned by the binding. |
Value serialization |
Each binding has its own value type ( |
Embedder construction |
Bindings construct (or load) the |
Where to read the code¶
src/graph/session/mod.rs— module-level rationale + re-exports.src/graph/session/execute.rs—ExecuteOptions,ExecuteOutcome,execute_read,execute_mut(the canonical pipeline).src/graph/session/transaction.rs—Session,Transaction,CommitOutcomeplus ~15 unit tests pinning the contract.src/lib.rs::api::session— public re-exports.src/graph/pyapi/kg_core.rs::cypher— Python wrapper (≈80 lines after Phase E).crates/kglite-bolt-server/src/backend.rs— Bolt wrapper (≈350 lines).crates/kglite-mcp-server/src/tools.rs::run_cypher_inner— MCP wrapper.