Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
42 commits
Select commit Hold shift + click to select a range
59ce1b2
feat(client): interactive transactions
polaz Aug 31, 2026
7a62021
test(sdk): cover transactions against a live server
polaz Aug 31, 2026
5726129
docs(client): document and demonstrate transactions
polaz Aug 31, 2026
95d5121
docs: list the fourth notebook in the README table
polaz Aug 31, 2026
fa0bf33
Merge branch 'main' into feat/#23-transactions
polaz Aug 31, 2026
50978c4
fix(client): stop reading lost RPCs as answered rejections
polaz Aug 31, 2026
a964f62
fix(client): refuse a zero handle and pair result columns strictly
polaz Aug 31, 2026
c61763a
fix(client): causal reads need a majority read concern, not write con…
polaz Aug 31, 2026
1cf17c8
fix(client): close the transaction on failures the code checks could …
polaz Aug 31, 2026
c797095
fix(client): free a cancelled statement's transaction on the server
polaz Sep 1, 2026
9a0d8b3
docs(client): require server v0.5.5 for transactions
polaz Sep 1, 2026
fe08fde
fix(client): judge a commit failure by proof the server answered
polaz Sep 1, 2026
01e93f2
fix(client): drain cancellation cleanups at close and bound their dea…
polaz Sep 1, 2026
a80d87d
docs(client): make the transaction examples self-contained
polaz Sep 1, 2026
3f42545
fix(client): keep cleanup failures from crashing or masking the caller
polaz Sep 1, 2026
8cb0498
docs(client): catch BaseException in the manual transaction example
polaz Sep 1, 2026
8f60ece
fix(client): close the remaining cleanup races and bounds
polaz Sep 1, 2026
15391cc
fix(client): finish the shutdown and interruption story for transactions
polaz Sep 1, 2026
20f445e
fix(client): refuse concurrent operations while a commit is in flight
polaz Sep 1, 2026
db68994
fix(client): serialize statements with the same in-flight guard as co…
polaz Sep 1, 2026
8f3d655
fix(client): close the round of cleanup-lifecycle edge cases
polaz Sep 1, 2026
f82f0e3
fix(client): make shutdown cancellation-independent and close CM canc…
polaz Sep 1, 2026
e627f5e
fix(client): serialize connect() with an in-flight shutdown
polaz Sep 1, 2026
19f04d5
fix(client): abandoned stragglers, pre-start cleanup window, caught a…
polaz Sep 1, 2026
867c140
fix(client): keep failed cleanup retriable and mirror indeterminate e…
polaz Sep 1, 2026
4a8ce8a
fix(client): contest abandoned commits, retriable rollbacks, interrup…
polaz Sep 1, 2026
11b04f1
fix(client): never swallow cancellation in cleanups, narrow sync inte…
polaz Sep 1, 2026
e0a16ed
fix(client): retry interrupted exceptional rollbacks, settle sync int…
polaz Sep 1, 2026
75de766
fix(client): retry unconfirmed cleanup on normal exit, bound sync int…
polaz Sep 1, 2026
5ee1771
fix(client): detach cancelled direct rollbacks, retry on exceptional …
polaz Sep 1, 2026
0d8e82b
fix(client): reclaim cancelled begins, detach async interrupt unwind,…
polaz Sep 1, 2026
2b383cf
fix(client): detach the cancelled aborted-branch rollback retry
polaz Sep 1, 2026
a072527
fix(client): begin on real grpc.aio stubs, settle interrupts, confirm…
polaz Sep 1, 2026
8a1b51d
fix(client): bound the begin reclaimer, track in-flight rollbacks, ke…
polaz Sep 1, 2026
65dcc14
fix(client): keep real outcomes over transport cancellations, see que…
polaz Sep 1, 2026
d4715fc
fix(client): settle cancellation at the scheduling yield, keep block …
polaz Sep 1, 2026
57142f3
docs(client): say why the in-flight guard stops where it does
polaz Sep 1, 2026
f85f618
build(proto): advance to the connection-status session frames
polaz Sep 1, 2026
729eb3f
fix(transactions): settle a rollback the server answers as unknown
polaz Sep 1, 2026
1fbdc00
refactor(transactions): give the bounded cleanup one implementation
polaz Sep 1, 2026
54eca2e
build(deps): track CoordiNode 0.5.7
polaz Sep 1, 2026
7b6ac6d
build(embedded): refresh the lock the driver bump left behind
polaz Sep 1, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -140,10 +140,10 @@ jobs:
services:
coordinode:
# Pinned by digest, not by tag: the proto submodule pins a server
# version, and a tag can be re-pushed, so `:0.5.5` alone does not name
# one fixed server. The digest below is 0.5.5; bump both together with
# version, and a tag can be re-pushed, so `:0.5.7` alone does not name
# one fixed server. The digest below is 0.5.7; bump both together with
# the submodule.
image: ghcr.io/structured-world/coordinode@sha256:8d3554be7680aa2cea7b6e773037aee97513865f4698b6df0ffc2daf845c42b8
image: ghcr.io/structured-world/coordinode@sha256:75a6242beb4cea8ab6726c842898fafc03ca9f59c6f7e36e462934a4ca64874c
ports:
- 7080:7080
- 7084:7084
Expand Down
88 changes: 86 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,17 +19,24 @@ Graph + Vector + Full-Text in a single transactional engine. One client, one que

## Try It in Google Colab

No setup required — runs entirely in-browser using the embedded engine:
The first four need no setup and run entirely in-browser on the embedded engine:

| Notebook | Open |
|----------|------|
| 00 · Seed demo knowledge graph | [![Open in Colab](https://colab.research.google.com/assets/colab-badge.svg)](https://colab.research.google.com/github/structured-world/coordinode-python/blob/main/demo/notebooks/00_seed_data.ipynb) |
| 01 · LlamaIndex PropertyGraph query | [![Open in Colab](https://colab.research.google.com/assets/colab-badge.svg)](https://colab.research.google.com/github/structured-world/coordinode-python/blob/main/demo/notebooks/01_llama_index_property_graph.ipynb) |
| 02 · LangChain GraphCypherQAChain | [![Open in Colab](https://colab.research.google.com/assets/colab-badge.svg)](https://colab.research.google.com/github/structured-world/coordinode-python/blob/main/demo/notebooks/02_langchain_graph_chain.ipynb) |
| 03 · LangGraph agent over graph | [![Open in Colab](https://colab.research.google.com/assets/colab-badge.svg)](https://colab.research.google.com/github/structured-world/coordinode-python/blob/main/demo/notebooks/03_langgraph_agent.ipynb) |
| 04 · What 0.5 added, transactions included | [![Open in Colab](https://colab.research.google.com/assets/colab-badge.svg)](https://colab.research.google.com/github/structured-world/coordinode-python/blob/main/demo/notebooks/04_whats_new_in_0_5.ipynb) |

> Start with **00** to seed the graph the other notebooks read from it.
> Start with **00** to seed the graph, which the other notebooks read from.
> The first cell installs pre-built wheels from PyPI (~30 sec).
>
> **04 is the exception:** batch writes, consistency levels, time travel and
> transactions are distribution and durability features, so it needs a server
> rather than the embedded engine. Point `COORDINODE_ADDR` at one, or run the
> Docker Compose stack in `demo/`. Without it the notebook stops with an
> explanation instead of failing cell by cell.

## Quick Start

Expand All @@ -56,6 +63,83 @@ with CoordinodeClient("localhost:7080") as db:
print(row["name"])
```

## Transactions

`db.cypher(...)` commits each statement on its own. To make several statements
Comment thread
polaz marked this conversation as resolved.
land together, or not at all, run them in a transaction (each snippet opens
its own client, so it runs as pasted):

```python
from coordinode import CoordinodeClient

with CoordinodeClient("localhost:7080") as db:
with db.transaction() as tx:
tx.cypher("CREATE (:Person {name: $n})", params={"n": "Alice"})
tx.cypher("CREATE (:Person {name: $n})", params={"n": "Bob"})
# commits here; an exception anywhere in the block rolls back
# instead, leaving neither person in the database
```

The same surface is on `AsyncCoordinodeClient`, with `async with` and awaited
statements. When the commit point sits outside a block, drive it by hand:

```python
from contextlib import suppress

from coordinode import CoordinodeClient

with CoordinodeClient("localhost:7080") as db:
tx = db.begin_transaction()
try:
tx.cypher("MERGE (n:Entity {name: $n})", params={"n": "Alice"})
applied_index = tx.commit()
except BaseException:
# BaseException so an interrupt (Ctrl-C) still frees the server-side
# transaction; the rollback failure is suppressed so it cannot
# replace the error that caused it.
with suppress(Exception):
tx.rollback()
Comment on lines +100 to +101

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Preserve the original error in the manual cleanup recipe

When tx.rollback() raises a transport-originated asyncio.CancelledError, this suppress(Exception) does not catch it because CancelledError derives from BaseException. The cleanup error therefore replaces the statement, commit, or interrupt that entered this handler, contradicting the example's stated guarantee; mirror the synchronous transaction context's explicit suppression of asyncio.CancelledError here.

Useful? React with 👍 / 👎.

raise
```

Requires a CoordiNode server of **v0.5.7 or newer** — the release this client
is integration-tested against. `health()` exercises a different service, so a
server without the transaction RPCs passes the health check and then refuses
`transaction()`.

Each statement reads the snapshot taken when the transaction began, so the
transaction sees a stable view of the database plus its own uncommitted writes,
which nobody else can see until the commit. A conflict with another transaction
that wrote the same data is reported by `commit()`, not by the statement, and a
rejected commit applies nothing. `commit()` returns the Raft applied index, which
a later read can pass as `after_index` (with `read_concern="majority"`) when it
must observe these writes.

`tx.cypher()` takes no consistency arguments, unlike `db.cypher()`: the snapshot
is already fixed and durability is decided once at the commit, so a per-statement
read or write concern has nothing left to mean.

Three constraints are worth knowing before holding a transaction open:

- **It belongs to one node.** The handle lives in the memory of the server that
opened it, so every request of the transaction must reach that same node.
Connect to a node's own address, or through a proxy configured for backend
affinity. A single client is *not* by itself a guarantee: against a layer-7
or per-request gRPC balancer the calls can be spread across backends, and a
reconnection can move to another backend mid-transaction, after which the
next statement fails with an unknown transaction id.
- **Idle transactions are collected.** The server reaps one that has been idle
(30 seconds by default), and it sweeps when another transaction begins rather
than on a timer, so a long pause between statements can lose the handle. A
failed statement also ends the transaction outright: its writes are discarded
and the handle is closed, so reusing it raises rather than reporting a
confusing error from the server.
- **A lost reply is not an abort.** If the connection drops or a deadline
expires while committing, the server may have applied everything or nothing,
and the client cannot tell. The transaction is marked indeterminate: later
calls on it say so, and `rollback()` raises instead of promising a discard.
Verify the data rather than blindly retrying, which can duplicate the writes.

## LangChain — GraphRAG Pipeline

```python
Expand Down
36 changes: 18 additions & 18 deletions coordinode-embedded/Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

5 changes: 5 additions & 0 deletions coordinode-embedded/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,11 @@ name = "_coordinode_embedded"
crate-type = ["cdylib"]

[dependencies]
# Moving the coordinode-rs submodule means refreshing Cargo.lock here in the
# same commit. These are path dependencies, so their own registry dependencies
# are resolved by THIS lock: an engine release that needs a newer storage
# engine still compiles against whatever version the lock names, and fails on
# the API it expected to find.
pyo3 = { version = "0.24", features = ["extension-module", "abi3-py311"] }
numpy = "0.24"
coordinode-embed = { path = "../coordinode-rs/crates/coordinode-embed" }
Expand Down
2 changes: 1 addition & 1 deletion coordinode-rs
Submodule coordinode-rs updated 254 files
15 changes: 10 additions & 5 deletions coordinode/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -171,17 +171,22 @@ db.cypher(
read_concern="majority",
)

# Majority write (required for causal reads)
# Durable write, acknowledged by a majority of the cluster
db.cypher("CREATE (n:Event {t: timestamp()})", write_concern="majority")

# Causal read: see at least state at raft index 42
db.cypher("MATCH (n) RETURN count(n) AS total", after_index=42)
# Causal read: see at least the state at raft index 42. The fence is about
# which replica may answer, so it is the READ concern that has to be majority.
db.cypher(
"MATCH (n) RETURN count(n) AS total",
after_index=42,
read_concern="majority",
)
```

Accepted values:

- ``read_concern``: ``local`` (default) · ``majority`` · ``linearizable`` · ``snapshot``
- ``write_concern``: ``w0`` · ``w1`` (default) · ``majority``
- ``read_concern``: ``local`` (default) · ``majority`` · ``linearizable`` · ``snapshot``. Causal reads (``after_index`` > 0) require ``majority`` here.
- ``write_concern``: ``w0`` · ``memory`` · ``cache`` · ``w1`` (default) · ``majority``
- ``read_preference``: ``primary`` (default) · ``primary_preferred`` · ``secondary`` · ``secondary_preferred`` · ``nearest``

## Related Packages
Expand Down
9 changes: 8 additions & 1 deletion coordinode/coordinode/__init__.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
"""
CoordiNode Python SDK graph + vector + full-text in one query.
CoordiNode Python SDK: graph + vector + full-text in one query.

Quick start::

Expand All @@ -21,6 +21,7 @@
from coordinode._types import MultiVector, Path
from coordinode.client import (
AsyncCoordinodeClient,
AsyncTransaction,
CoordinodeClient,
EdgeResult,
EdgeTypeInfo,
Expand All @@ -29,6 +30,7 @@
PropertyDefinitionInfo,
TextIndexInfo,
TextResult,
Transaction,
TraverseResult,
VectorResult,
)
Expand All @@ -40,6 +42,11 @@
__all__ = [
"CoordinodeClient",
"AsyncCoordinodeClient",
# Handles for a group of statements that commits or rolls back as one.
# Exported for type annotations; both come from a client, never built
# directly, since only the server can hand out the transaction id.
"Transaction",
"AsyncTransaction",
# Values whose wire type only a tag can carry: a plain nested list encodes
# as a list and a plain dict as a map, so sending either of these types
# requires the constructor, not just the shape.
Expand Down
Loading
Loading