Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
55 changes: 12 additions & 43 deletions .github/workflows/reusable_buildtest.yml
Original file line number Diff line number Diff line change
Expand Up @@ -70,49 +70,20 @@ jobs:
run: |
# High availability is a Memgraph Enterprise feature. The license
# secrets are unavailable on PRs from forks, so skip the cluster (and
# the HA test) with a warning rather than failing the whole build.
# the HA tests) with a warning rather than failing the whole build.
if [ -z "$MEMGRAPH_ENTERPRISE_LICENSE" ] || [ -z "$MEMGRAPH_ORGANIZATION_NAME" ]; then
echo "::warning::Memgraph enterprise license secrets are not set; skipping the HA cluster and the get_routing_table HA test."
echo "::warning::Memgraph enterprise license secrets are not set; skipping the HA cluster and the routing tests."
exit 0
fi

# Two data instances...
for name in mg-data1 mg-data2; do
docker run -d --rm --name "$name" --network pymgclient-network \
-e MEMGRAPH_ENTERPRISE_LICENSE="$MEMGRAPH_ENTERPRISE_LICENSE" \
-e MEMGRAPH_ORGANIZATION_NAME="$MEMGRAPH_ORGANIZATION_NAME" \
memgraph/memgraph:latest \
--bolt-port=7687 \
--management-port=13011 \
--telemetry-enabled=false
done

# ...and three coordinators for a Raft quorum. mg-coord1 is the
# bootstrap leader; the test harness adds the other two coordinators,
# registers the instances and elects a main once the containers are up.
for id in 1 2 3; do
docker run -d --rm --name "mg-coord${id}" --network pymgclient-network \
-e MEMGRAPH_ENTERPRISE_LICENSE="$MEMGRAPH_ENTERPRISE_LICENSE" \
-e MEMGRAPH_ORGANIZATION_NAME="$MEMGRAPH_ORGANIZATION_NAME" \
memgraph/memgraph:latest \
--bolt-port=7687 \
--coordinator-id="${id}" \
--coordinator-port=12121 \
--coordinator-hostname="mg-coord${id}" \
--management-port=13011 \
--telemetry-enabled=false
done

# Fail with a clear message if the cluster didn't come up, rather than
# as an opaque DNS error when the test connects to the coordinator.
sleep 10
for name in mg-data1 mg-data2 mg-coord1 mg-coord2 mg-coord3; do
if [ "$(docker inspect -f '{{.State.Running}}' "$name" 2>/dev/null)" != "true" ]; then
echo "::error::Memgraph HA container $name is not running"
docker logs "$name" || true
exit 1
fi
done
# Start a 3-data + 3-coordinator cluster on the test runner's Docker
# network, using the shared script from the mgclient submodule. In
# bridge mode it addresses instances by container name (mg-*:7687),
# which the test container resolves; the script also registers the
# topology and waits for the cluster to converge.
MEMGRAPH_HA_NETWORK=pymgclient-network \
MEMGRAPH_HA_IMAGE=memgraph/memgraph:latest \
bash mgclient/tool/ha_cluster.sh start
Comment thread
mattkjames7 marked this conversation as resolved.

- name: Build Docker Image
run: |
Expand Down Expand Up @@ -163,7 +134,7 @@ jobs:
# when the enterprise license secrets are unavailable, e.g. forks).
if [ "$(docker inspect -f '{{.State.Running}}' mg-coord1 2>/dev/null)" = "true" ]; then
docker exec -i testcontainer \
bash -c "export MEMGRAPH_HA_COORDINATOR_HOST=mg-coord1 && cd /home/memgraph/pymgclient && python$python_version -m pytest -v test/test_connection.py -k get_routing_table_ha"
bash -c "export MEMGRAPH_HA_COORDINATOR_HOST=mg-coord1 && cd /home/memgraph/pymgclient && python$python_version -m pytest -v test/test_routing.py"
fi
done

Expand Down Expand Up @@ -192,9 +163,7 @@ jobs:
docker wait memgraph || echo "Memgraph container does not exist"
docker stop memgraph-ssl || echo "Memgraph container with SSL does not exist"
docker wait memgraph-ssl || echo "Memgraph container with SSL does not exist"
for name in mg-coord1 mg-coord2 mg-coord3 mg-data1 mg-data2; do
docker stop "$name" || echo "$name does not exist"
done
bash mgclient/tool/ha_cluster.sh stop || echo "HA cluster does not exist"
docker rmi pymgclient-builder:latest || echo "Image does not exist"
docker network rm pymgclient-network || echo "Network does not exist"

Expand Down
58 changes: 56 additions & 2 deletions docs/source/module.rst
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,54 @@ The module interface respects the DB-API 2.0 standard defined in :pep:`249`.
See :ref:`lazy-connections` section to learn about
advantages and limitations of using the ``lazy`` parameter.

#####################
Client-side routing
#####################

When connecting to a Memgraph high-availability cluster, passing
``routing=True`` to :func:`connect` makes the connection routing-aware: the
given ``host``/``port`` must point at a cluster coordinator, from which the
current cluster topology is fetched (via a Bolt ``ROUTE`` message) and a data
instance matching the requested ``access_mode`` is selected and connected to.

.. data:: mgclient.ACCESS_MODE_WRITE

``access_mode`` value selecting a server that accepts writes (the cluster
main). This is the default.

.. data:: mgclient.ACCESS_MODE_READ

``access_mode`` value selecting a server that serves reads (a replica).

If the addresses a cluster advertises are not directly reachable by the client
(for example when the cluster is reached through a proxy or a port-forward),
pass a ``resolver`` callable that maps an advertised ``"host:port"`` address to
an iterable of ``"host:port"`` targets to try.

``connect(routing=True, ...)`` performs a fresh routing lookup on every call.
For a long-lived router that caches the routing table (honouring its TTL),
balances reads across replicas and fails over across coordinators, use the
:class:`Router` class:

.. autoclass:: mgclient.Router
:members: connect, execute_read, execute_write, refresh, routing_table

:meth:`Router.execute_read` and :meth:`Router.execute_write` are *managed
transactions*: they run your unit of work against the right instance and
automatically retry transient conditions -- an instance briefly unreachable
during a failover, a replica still catching up, or a connection dropped
mid-request -- with a routing refresh and capped exponential backoff. Because
the work may run more than once, make it idempotent (e.g. ``MERGE`` rather than
``CREATE``) so a retry cannot duplicate a write.

The classification used for retries is also exposed for building your own retry
loops:

.. autofunction:: mgclient.is_transient_error

For lower-level access to the routing table itself, see
:meth:`Connection.get_routing_table`.

################
Module constants
################
Expand Down Expand Up @@ -54,6 +102,8 @@ through these exceptions or subclasses thereof:

.. autoexception:: mgclient.OperationalError

.. autoexception:: mgclient.TransientError

.. autoexception:: mgclient.IntegrityError

.. autoexception:: mgclient.InternalError
Expand All @@ -64,8 +114,12 @@ through these exceptions or subclasses thereof:

.. NOTE::

In the current state, :exc:`OperationalError` is raised for all errors
obtained from the database. This will probably be improved in the future.
Most database errors are surfaced as :exc:`DatabaseError` (with
connection-related failures raised as :exc:`OperationalError`). Retryable
conditions such as a high-availability failover are raised as
:exc:`TransientError` (a subclass of :exc:`OperationalError`); see
:func:`is_transient_error`.


##################
Graph type objects
Expand Down
61 changes: 61 additions & 0 deletions docs/source/usage.rst
Original file line number Diff line number Diff line change
Expand Up @@ -124,3 +124,64 @@ will be immediately committed and no rollback is possible. A few commands
(``CREATE INDEX``, ``CREATE USER`` and similar) require to be run outside any
transaction. To set the connection in *autocommit* mode, set
:attr:`.autocommit` property of the connection to ``True``.

##############################################
Connecting to a high-availability cluster
##############################################

When connecting to a Memgraph high-availability (HA) cluster you connect to a
*coordinator* and let :mod:`mgclient` route each query to the right data
instance: writes to the main, reads to a replica. Pass ``routing=True`` to
:func:`.connect` and choose an ``access_mode``::

>>> import mgclient

# Routed to the main (writes)
>>> conn = mgclient.connect(host="coordinator-1", port=7687,
... username="user", password="pass",
... routing=True,
... access_mode=mgclient.ACCESS_MODE_WRITE)
>>> cursor = conn.cursor()
>>> cursor.execute("MERGE (:Person {name: $name})", {"name": "Ada"})
>>> conn.commit()

# Routed to a replica (reads)
>>> reader = mgclient.connect(host="coordinator-1", port=7687,
... username="user", password="pass",
... routing=True,
... access_mode=mgclient.ACCESS_MODE_READ)

Each :func:`.connect` call above performs a fresh routing lookup. For a
long-lived client, use a :class:`Router` instead: it caches the routing table,
balances reads across replicas, and fails over across coordinators::

>>> router = mgclient.Router(host="coordinator-1", port=7687,
... username="user", password="pass")
>>> writer = router.connect(access_mode=mgclient.ACCESS_MODE_WRITE)
>>> reader = router.connect(access_mode=mgclient.ACCESS_MODE_READ)

To keep working through transient cluster conditions -- an instance briefly
unreachable during a failover, a replica still catching up, or a connection
dropped mid-request -- run your queries through the *managed* transaction
helpers, which retry them automatically::

>>> def create_person(cursor):
... cursor.execute("CREATE (p:Person {name: $n}) RETURN id(p)", {"n": "Ada"})
Comment thread
mattkjames7 marked this conversation as resolved.
... return cursor.fetchall()[0][0]
>>> new_id = router.execute_write(create_person) # committed for you

>>> def count_people(cursor):
... cursor.execute("MATCH (:Person) RETURN count(*)")
... return cursor.fetchall()[0][0]
>>> total = router.execute_read(count_people)

If the addresses the cluster advertises are not directly reachable from the
client (for example in-cluster Kubernetes names reached from outside), pass a
``resolver`` that maps an advertised ``"host:port"`` to reachable targets::

>>> def resolver(advertised):
... mapping = {"memgraph-data-0.default.svc.cluster.local:7687": "10.0.0.5:7687"}
... return [mapping.get(advertised, advertised)]
>>> router = mgclient.Router(host="coordinator-1", port=7687, resolver=resolver)

See the :doc:`module` reference for the full routing API.
5 changes: 2 additions & 3 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -46,9 +46,8 @@ Documentation = "https://memgraph.github.io/pymgclient"

[tool.setuptools]
py-modules = ["tools.pymgclient_build_meta"]

[tool.setuptools.packages.find]
include = ["tools*"]
packages = ["tools", "mgclient"]
package-dir = { mgclient = "python/mgclient" }

[tool.setuptools.dynamic]
version = { attr = "tools.pymgclient_build_meta.__version__" }
29 changes: 29 additions & 0 deletions python/mgclient/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
# Copyright (c) 2016-2026 Memgraph Ltd. [https://memgraph.com]
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.

"""Memgraph database adapter for Python, compliant with the DB-API 2.0
specification described by :pep:`249`.
"""

from mgclient._mgclient import * # noqa: F401,F403

# The routing-aware ``connect`` wrapper replaces the C ``connect`` imported
# above; with routing disabled (the default) it delegates straight to it.
from mgclient.routing import ( # noqa: F401,E402
ACCESS_MODE_READ,
ACCESS_MODE_WRITE,
Router,
connect,
is_transient_error,
)
Loading
Loading