From b8674c41d6100ea65fc3be90763d38a3a22955b6 Mon Sep 17 00:00:00 2001 From: matt Date: Wed, 8 Jul 2026 14:58:23 +0100 Subject: [PATCH 01/20] reouting table parsing --- include/mgclient.h | 54 ++++++++++++- src/CMakeLists.txt | 3 +- src/mgrouting.c | 176 +++++++++++++++++++++++++++++++++++++++++++ tests/CMakeLists.txt | 3 +- tests/routing.cpp | 150 ++++++++++++++++++++++++++++++++++++ 5 files changed, 383 insertions(+), 3 deletions(-) create mode 100644 src/mgrouting.c create mode 100644 tests/routing.cpp diff --git a/include/mgclient.h b/include/mgclient.h index d104658..8715fa2 100644 --- a/include/mgclient.h +++ b/include/mgclient.h @@ -1,4 +1,4 @@ -// Copyright (c) 2016-2020 Memgraph Ltd. [https://memgraph.com] +// 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. @@ -1422,6 +1422,58 @@ MGCLIENT_EXPORT int mg_session_route(mg_session *session, const mg_map *routing, const mg_map *extra, mg_map **routing_table); +/// The role a server plays in a client-side routing table. +enum mg_routing_role { + MG_ROUTING_ROLE_READ, + MG_ROUTING_ROLE_WRITE, + MG_ROUTING_ROLE_ROUTE, +}; + +/// A parsed client-side routing table: the advertised "host:port" addresses +/// grouped by role, together with the table's time-to-live. Build one from the +/// map returned by \ref mg_session_route using \ref mg_routing_table_parse. +/// +/// \ref mg_routing_table is an opaque data type. The addresses of every server +/// sharing a role are flattened into a single list for that role, in the order +/// they appear in the source map. +typedef struct mg_routing_table mg_routing_table; + +/// Parses the routing-table map returned by \ref mg_session_route. +/// +/// \param raw The routing-table map (see \ref mg_session_route for its shape). +/// The map is only read; ownership is not taken. +/// +/// \return A freshly allocated \ref mg_routing_table (ownership transferred to +/// the caller, who must call \ref mg_routing_table_destroy), or NULL if +/// \p raw is NULL or an allocation failed. +/// +/// Parsing is lenient: only \p raw being NULL (or an allocation +/// failure) causes a NULL result. Anything malformed is skipped rather +/// than treated as an error -- a missing or non-integer "ttl" defaults +/// to 0, a missing or non-list "servers" yields an empty table, and any +/// server entry that is not a map, whose "role" is missing/non-string +/// or not one of "READ"/"WRITE"/"ROUTE", or whose "addresses" are +/// missing/non-list is ignored (individual non-string addresses are +/// skipped too). +MGCLIENT_EXPORT mg_routing_table *mg_routing_table_parse(const mg_map *raw); + +/// Destroys a \ref mg_routing_table. +MGCLIENT_EXPORT void mg_routing_table_destroy(mg_routing_table *table); + +/// Returns the time-to-live of the routing table, in seconds. +MGCLIENT_EXPORT int64_t mg_routing_table_ttl(const mg_routing_table *table); + +/// Returns the number of advertised addresses for \p role. +MGCLIENT_EXPORT uint32_t mg_routing_table_address_count( + const mg_routing_table *table, enum mg_routing_role role); + +/// Returns the \p index-th advertised "host:port" address for \p role. +/// +/// The returned string is NUL-terminated and owned by \p table (valid until it +/// is destroyed). Returns NULL if \p index is out of range. +MGCLIENT_EXPORT const char *mg_routing_table_address_at( + const mg_routing_table *table, enum mg_routing_role role, uint32_t index); + /// Starts an Explicit transaction on the server. /// /// Every run will be part of that transaction until its explicitly ended. diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt index 92426e9..6ede31e 100644 --- a/src/CMakeLists.txt +++ b/src/CMakeLists.txt @@ -1,4 +1,4 @@ -# Copyright (c) 2016-2020 Memgraph Ltd. [https://memgraph.com] +# 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. @@ -20,6 +20,7 @@ set(mgclient_src_files mgallocator.c mgclient.c mgmessage.c + mgrouting.c mgsession.c mgsession-decoder.c mgsession-encoder.c diff --git a/src/mgrouting.c b/src/mgrouting.c new file mode 100644 index 0000000..eeb4b9d --- /dev/null +++ b/src/mgrouting.c @@ -0,0 +1,176 @@ +// 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. + +#include "mgclient.h" + +#include +#include +#include +#include + +// A growable array of owned, NUL-terminated address strings. +typedef struct { + char **items; + uint32_t size; + uint32_t capacity; +} mg_addr_list; + +struct mg_routing_table { + int64_t ttl; + // Addresses grouped by role, indexed by enum mg_routing_role. + mg_addr_list roles[3]; +}; + +static int role_is_valid(enum mg_routing_role role) { + return role == MG_ROUTING_ROLE_READ || role == MG_ROUTING_ROLE_WRITE || + role == MG_ROUTING_ROLE_ROUTE; +} + +// Map a role string (not NUL-terminated) to the enum. Returns 0 on success. +static int role_from_string(const mg_string *str, enum mg_routing_role *out) { + const char *data = mg_string_data(str); + uint32_t size = mg_string_size(str); + if (size == 4 && memcmp(data, "READ", 4) == 0) { + *out = MG_ROUTING_ROLE_READ; + return 0; + } + if (size == 5 && memcmp(data, "WRITE", 5) == 0) { + *out = MG_ROUTING_ROLE_WRITE; + return 0; + } + if (size == 5 && memcmp(data, "ROUTE", 5) == 0) { + *out = MG_ROUTING_ROLE_ROUTE; + return 0; + } + return -1; +} + +// Append a copy of `size` bytes of `data` (NUL-terminating it) to `list`. +static int addr_list_append(mg_addr_list *list, const char *data, + uint32_t size) { + if (list->size == list->capacity) { + uint32_t new_capacity = list->capacity ? list->capacity * 2 : 4; + char **new_items = + (char **)realloc(list->items, new_capacity * sizeof(char *)); + if (!new_items) { + return -1; + } + list->items = new_items; + list->capacity = new_capacity; + } + char *copy = (char *)malloc((size_t)size + 1); + if (!copy) { + return -1; + } + memcpy(copy, data, size); + copy[size] = '\0'; + list->items[list->size++] = copy; + return 0; +} + +mg_routing_table *mg_routing_table_parse(const mg_map *raw) { + if (!raw) { + return NULL; + } + mg_routing_table *table = + (mg_routing_table *)calloc(1, sizeof(mg_routing_table)); + if (!table) { + return NULL; + } + + const mg_value *ttl = mg_map_at(raw, "ttl"); + if (ttl && mg_value_get_type(ttl) == MG_VALUE_TYPE_INTEGER) { + table->ttl = mg_value_integer(ttl); + } + + const mg_value *servers = mg_map_at(raw, "servers"); + if (servers && mg_value_get_type(servers) == MG_VALUE_TYPE_LIST) { + const mg_list *server_list = mg_value_list(servers); + uint32_t server_count = mg_list_size(server_list); + for (uint32_t i = 0; i < server_count; ++i) { + const mg_value *server_value = mg_list_at(server_list, i); + if (!server_value || + mg_value_get_type(server_value) != MG_VALUE_TYPE_MAP) { + continue; + } + const mg_map *server = mg_value_map(server_value); + + const mg_value *role_value = mg_map_at(server, "role"); + if (!role_value || + mg_value_get_type(role_value) != MG_VALUE_TYPE_STRING) { + continue; + } + enum mg_routing_role role; + if (role_from_string(mg_value_string(role_value), &role) != 0) { + continue; // Unrecognised role -- ignore this server. + } + + const mg_value *addresses = mg_map_at(server, "addresses"); + if (!addresses || + mg_value_get_type(addresses) != MG_VALUE_TYPE_LIST) { + continue; + } + const mg_list *address_list = mg_value_list(addresses); + uint32_t address_count = mg_list_size(address_list); + for (uint32_t j = 0; j < address_count; ++j) { + const mg_value *address = mg_list_at(address_list, j); + if (!address || mg_value_get_type(address) != MG_VALUE_TYPE_STRING) { + continue; + } + const mg_string *str = mg_value_string(address); + if (addr_list_append(&table->roles[role], mg_string_data(str), + mg_string_size(str)) != 0) { + mg_routing_table_destroy(table); + return NULL; + } + } + } + } + + return table; +} + +void mg_routing_table_destroy(mg_routing_table *table) { + if (!table) { + return; + } + for (size_t r = 0; r < sizeof(table->roles) / sizeof(table->roles[0]); ++r) { + for (uint32_t i = 0; i < table->roles[r].size; ++i) { + free(table->roles[r].items[i]); + } + free(table->roles[r].items); + } + free(table); +} + +int64_t mg_routing_table_ttl(const mg_routing_table *table) { + return table ? table->ttl : 0; +} + +uint32_t mg_routing_table_address_count(const mg_routing_table *table, + enum mg_routing_role role) { + if (!table || !role_is_valid(role)) { + return 0; + } + return table->roles[role].size; +} + +const char *mg_routing_table_address_at(const mg_routing_table *table, + enum mg_routing_role role, + uint32_t index) { + if (!table || !role_is_valid(role) || index >= table->roles[role].size) { + return NULL; + } + return table->roles[role].items[index]; +} diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index 12bb30f..3b540d7 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -1,4 +1,4 @@ -# Copyright (c) 2016-2020 Memgraph Ltd. [https://memgraph.com] +# 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. @@ -40,6 +40,7 @@ endmacro() add_gtest(value value.cpp) add_gtest(encoder encoder.cpp) add_gtest(decoder decoder.cpp) +add_gtest(routing routing.cpp) add_gtest(client client.cpp) # We're mocking the mg_secure_transport_init function in the test. if(MGCLIENT_ON_APPLE) diff --git a/tests/routing.cpp b/tests/routing.cpp new file mode 100644 index 0000000..0317081 --- /dev/null +++ b/tests/routing.cpp @@ -0,0 +1,150 @@ +// 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. + +#include + +#include + +#include "mgclient.h" + +namespace { + +// Build a `mg_value` list-of-strings from the given addresses. +mg_value *StringList(const std::vector &addrs) { + mg_list *list = mg_list_make_empty(static_cast(addrs.size())); + for (const char *addr : addrs) { + mg_list_append(list, mg_value_make_string(addr)); + } + return mg_value_make_list(list); +} + +// Build one `{"addresses": [...], "role": role}` server map value. +mg_value *Server(const std::vector &addrs, const char *role) { + mg_map *server = mg_map_make_empty(2); + mg_map_insert(server, "addresses", StringList(addrs)); + mg_map_insert(server, "role", mg_value_make_string(role)); + return mg_value_make_map(server); +} + +} // namespace + +TEST(RoutingTable, ParseGroupsAddressesByRole) { + mg_list *servers = mg_list_make_empty(4); + mg_list_append(servers, Server({"m:7687"}, "WRITE")); + mg_list_append(servers, Server({"r1:7687", "r2:7687"}, "READ")); + mg_list_append(servers, Server({"c1:7687", "c2:7687"}, "ROUTE")); + // A server with an unrecognised role must be ignored. + mg_list_append(servers, Server({"x:7687"}, "SOMETHING_ELSE")); + + mg_map *raw = mg_map_make_empty(2); + mg_map_insert(raw, "ttl", mg_value_make_integer(120)); + mg_map_insert(raw, "servers", mg_value_make_list(servers)); + + mg_routing_table *table = mg_routing_table_parse(raw); + ASSERT_NE(table, nullptr); + + EXPECT_EQ(mg_routing_table_ttl(table), 120); + + ASSERT_EQ(mg_routing_table_address_count(table, MG_ROUTING_ROLE_WRITE), 1u); + EXPECT_STREQ(mg_routing_table_address_at(table, MG_ROUTING_ROLE_WRITE, 0), + "m:7687"); + + ASSERT_EQ(mg_routing_table_address_count(table, MG_ROUTING_ROLE_READ), 2u); + EXPECT_STREQ(mg_routing_table_address_at(table, MG_ROUTING_ROLE_READ, 0), + "r1:7687"); + EXPECT_STREQ(mg_routing_table_address_at(table, MG_ROUTING_ROLE_READ, 1), + "r2:7687"); + + ASSERT_EQ(mg_routing_table_address_count(table, MG_ROUTING_ROLE_ROUTE), 2u); + EXPECT_STREQ(mg_routing_table_address_at(table, MG_ROUTING_ROLE_ROUTE, 0), + "c1:7687"); + EXPECT_STREQ(mg_routing_table_address_at(table, MG_ROUTING_ROLE_ROUTE, 1), + "c2:7687"); + + mg_routing_table_destroy(table); + mg_map_destroy(raw); +} + +TEST(RoutingTable, ParseNullReturnsNull) { + EXPECT_EQ(mg_routing_table_parse(nullptr), nullptr); +} + +TEST(RoutingTable, AddressAtOutOfRangeReturnsNull) { + mg_list *servers = mg_list_make_empty(1); + mg_list_append(servers, Server({"m:7687"}, "WRITE")); + mg_map *raw = mg_map_make_empty(2); + mg_map_insert(raw, "ttl", mg_value_make_integer(1)); + mg_map_insert(raw, "servers", mg_value_make_list(servers)); + + mg_routing_table *table = mg_routing_table_parse(raw); + ASSERT_NE(table, nullptr); + EXPECT_EQ(mg_routing_table_address_at(table, MG_ROUTING_ROLE_WRITE, 5), + nullptr); + EXPECT_EQ(mg_routing_table_address_count(table, MG_ROUTING_ROLE_READ), 0u); + + mg_routing_table_destroy(table); + mg_map_destroy(raw); +} + +TEST(RoutingTable, ParseIgnoresMalformedEntries) { + // A non-integer "ttl" defaults to 0; a server that is not a map, or whose + // "role"/"addresses" have the wrong type, is skipped -- none of this fails + // the parse. + mg_list *servers = mg_list_make_empty(4); + mg_list_append(servers, mg_value_make_integer(42)); // not a map + { + mg_map *no_role = mg_map_make_empty(1); + mg_map_insert(no_role, "addresses", StringList({"a:7687"})); + mg_list_append(servers, mg_value_make_map(no_role)); // missing "role" + } + { + mg_map *bad_addrs = mg_map_make_empty(2); + mg_map_insert(bad_addrs, "role", mg_value_make_string("WRITE")); + mg_map_insert(bad_addrs, "addresses", mg_value_make_integer(1)); // not a list + mg_list_append(servers, mg_value_make_map(bad_addrs)); + } + // A valid server survives alongside the malformed ones. + mg_list_append(servers, Server({"m:7687"}, "WRITE")); + + mg_map *raw = mg_map_make_empty(2); + mg_map_insert(raw, "ttl", mg_value_make_string("not-an-integer")); + mg_map_insert(raw, "servers", mg_value_make_list(servers)); + + mg_routing_table *table = mg_routing_table_parse(raw); + ASSERT_NE(table, nullptr); + EXPECT_EQ(mg_routing_table_ttl(table), 0); // non-integer ttl -> 0 + ASSERT_EQ(mg_routing_table_address_count(table, MG_ROUTING_ROLE_WRITE), 1u); + EXPECT_STREQ(mg_routing_table_address_at(table, MG_ROUTING_ROLE_WRITE, 0), + "m:7687"); + + mg_routing_table_destroy(table); + mg_map_destroy(raw); +} + +TEST(RoutingTable, ParseIsLenientAboutMissingKeys) { + // No "ttl" (defaults to 0) and an empty "servers" list yield an empty table, + // not NULL. + mg_map *raw = mg_map_make_empty(1); + mg_map_insert(raw, "servers", mg_value_make_list(mg_list_make_empty(0))); + + mg_routing_table *table = mg_routing_table_parse(raw); + ASSERT_NE(table, nullptr); + EXPECT_EQ(mg_routing_table_ttl(table), 0); + EXPECT_EQ(mg_routing_table_address_count(table, MG_ROUTING_ROLE_WRITE), 0u); + EXPECT_EQ(mg_routing_table_address_count(table, MG_ROUTING_ROLE_READ), 0u); + EXPECT_EQ(mg_routing_table_address_count(table, MG_ROUTING_ROLE_ROUTE), 0u); + + mg_routing_table_destroy(table); + mg_map_destroy(raw); +} From a3059083aa9c224934a7f548164422ee595223e3 Mon Sep 17 00:00:00 2001 From: matt Date: Wed, 8 Jul 2026 15:35:45 +0100 Subject: [PATCH 02/20] error classification --- include/mgclient.h | 20 +++++++++++++++++++ src/mgrouting.c | 49 ++++++++++++++++++++++++++++++++++++++++++++++ tests/routing.cpp | 38 +++++++++++++++++++++++++++++++++++ 3 files changed, 107 insertions(+) diff --git a/include/mgclient.h b/include/mgclient.h index 8715fa2..5ac59f5 100644 --- a/include/mgclient.h +++ b/include/mgclient.h @@ -1474,6 +1474,26 @@ MGCLIENT_EXPORT uint32_t mg_routing_table_address_count( MGCLIENT_EXPORT const char *mg_routing_table_address_at( const mg_routing_table *table, enum mg_routing_role role, uint32_t index); +/// Returns non-zero if \p error (an MG_ERROR_* status code) denotes a transient +/// condition worth retrying. +/// +/// This is the server's TransientError category (\ref MG_ERROR_TRANSIENT_ERROR) +/// or a low-level transport/connection failure (\ref MG_ERROR_SEND_FAILED, +/// \ref MG_ERROR_RECV_FAILED, \ref MG_ERROR_NETWORK_FAILURE, \ref +/// MG_ERROR_SOCKET) -- all of which may succeed on a retry after the cluster +/// reconverges. Non-transport failures (bad parameters, decoding/protocol +/// errors, SSL errors, and the client/database error categories) return 0. +MGCLIENT_EXPORT int mg_error_is_transient(int error); + +/// Returns non-zero if \p message reports a write that committed on the main +/// but could not be replicated to a synchronous replica. +/// +/// Such a write is durable, so it is safe to treat as success rather than +/// retry it (a retry would duplicate it). This is the one condition that has no +/// distinct error code and must be recognised from the message text (e.g. as +/// returned by \ref mg_session_error). \p message may be NULL (returns 0). +MGCLIENT_EXPORT int mg_error_is_committed_on_main(const char *message); + /// Starts an Explicit transaction on the server. /// /// Every run will be part of that transaction until its explicitly ended. diff --git a/src/mgrouting.c b/src/mgrouting.c index eeb4b9d..79aab6b 100644 --- a/src/mgrouting.c +++ b/src/mgrouting.c @@ -14,6 +14,7 @@ #include "mgclient.h" +#include #include #include #include @@ -174,3 +175,51 @@ const char *mg_routing_table_address_at(const mg_routing_table *table, } return table->roles[role].items[index]; } + +int mg_error_is_transient(int error) { + switch (error) { + // The server told us so (Bolt "TransientError" category). + case MG_ERROR_TRANSIENT_ERROR: + // Low-level transport/connection failures: no Bolt code, but retryable in + // an HA cluster (an instance dropped mid-request, or was momentarily + // unreachable during a failover). Non-transport failures such as + // MG_ERROR_BAD_PARAMETER, MG_ERROR_DECODING_FAILED, + // MG_ERROR_PROTOCOL_VIOLATION and MG_ERROR_SSL_ERROR are deliberately + // excluded. + case MG_ERROR_SEND_FAILED: + case MG_ERROR_RECV_FAILED: + case MG_ERROR_NETWORK_FAILURE: + case MG_ERROR_SOCKET: + return 1; + default: + return 0; + } +} + +// Case-insensitive substring search. `needle` is matched regardless of case; +// strcasestr is a non-standard extension so we roll our own for portability. +static int contains_ci(const char *haystack, const char *needle) { + if (!haystack || !needle) { + return 0; + } + size_t needle_len = strlen(needle); + if (needle_len == 0) { + return 1; + } + for (const char *h = haystack; *h; ++h) { + size_t i = 0; + while (i < needle_len && h[i] && + tolower((unsigned char)h[i]) == tolower((unsigned char)needle[i])) { + ++i; + } + if (i == needle_len) { + return 1; + } + } + return 0; +} + +int mg_error_is_committed_on_main(const char *message) { + return contains_ci(message, "replication exception") && + contains_ci(message, "committed on the main"); +} diff --git a/tests/routing.cpp b/tests/routing.cpp index 0317081..d18de85 100644 --- a/tests/routing.cpp +++ b/tests/routing.cpp @@ -80,6 +80,44 @@ TEST(RoutingTable, ParseNullReturnsNull) { EXPECT_EQ(mg_routing_table_parse(nullptr), nullptr); } +TEST(ErrorClassification, TransientCoversServerAndTransportFailures) { + // Server-signalled transient + low-level transport/connection failures. + EXPECT_TRUE(mg_error_is_transient(MG_ERROR_TRANSIENT_ERROR)); + EXPECT_TRUE(mg_error_is_transient(MG_ERROR_SEND_FAILED)); + EXPECT_TRUE(mg_error_is_transient(MG_ERROR_RECV_FAILED)); + EXPECT_TRUE(mg_error_is_transient(MG_ERROR_NETWORK_FAILURE)); + EXPECT_TRUE(mg_error_is_transient(MG_ERROR_SOCKET)); +} + +TEST(ErrorClassification, TransientIsFalseForNonTransportFailures) { + EXPECT_FALSE(mg_error_is_transient(0)); // success + EXPECT_FALSE(mg_error_is_transient(MG_ERROR_CLIENT_ERROR)); + EXPECT_FALSE(mg_error_is_transient(MG_ERROR_DATABASE_ERROR)); + EXPECT_FALSE(mg_error_is_transient(MG_ERROR_BAD_PARAMETER)); + EXPECT_FALSE(mg_error_is_transient(MG_ERROR_DECODING_FAILED)); + EXPECT_FALSE(mg_error_is_transient(MG_ERROR_PROTOCOL_VIOLATION)); + EXPECT_FALSE(mg_error_is_transient(MG_ERROR_SSL_ERROR)); +} + +TEST(ErrorClassification, CommittedOnMainNeedsBothMarkers) { + const char *committed = + "Replication Exception: Failed to replicate to SYNC replica 'instance_1': " + "replica is not reachable or not in sync with the main. Transaction is " + "still committed on the main instance and other alive replicas."; + EXPECT_TRUE(mg_error_is_committed_on_main(committed)); + + // Case-insensitive. + EXPECT_TRUE(mg_error_is_committed_on_main( + "REPLICATION EXCEPTION ... COMMITTED ON THE MAIN instance")); + + // A replication error where the transaction was aborted is NOT committed. + EXPECT_FALSE(mg_error_is_committed_on_main( + "Replication Exception: ... Transaction was aborted on all instances.")); + // Unrelated errors and NULL. + EXPECT_FALSE(mg_error_is_committed_on_main("Syntax error near 'FOO'")); + EXPECT_FALSE(mg_error_is_committed_on_main(nullptr)); +} + TEST(RoutingTable, AddressAtOutOfRangeReturnsNull) { mg_list *servers = mg_list_make_empty(1); mg_list_append(servers, Server({"m:7687"}, "WRITE")); From b5264d40a85339c05482c177674facc66f71e8a9 Mon Sep 17 00:00:00 2001 From: matt Date: Wed, 8 Jul 2026 18:05:37 +0100 Subject: [PATCH 03/20] added router --- include/mgclient.h | 123 +++++++++++ src/mgrouting.c | 507 ++++++++++++++++++++++++++++++++++++++++++++- src/mgrouting.h | 59 ++++++ tests/routing.cpp | 325 +++++++++++++++++++++++++++++ 4 files changed, 1007 insertions(+), 7 deletions(-) create mode 100644 src/mgrouting.h diff --git a/include/mgclient.h b/include/mgclient.h index 5ac59f5..c204297 100644 --- a/include/mgclient.h +++ b/include/mgclient.h @@ -1494,6 +1494,129 @@ MGCLIENT_EXPORT int mg_error_is_transient(int error); /// returned by \ref mg_session_error). \p message may be NULL (returns 0). MGCLIENT_EXPORT int mg_error_is_committed_on_main(const char *message); +/// An opaque accumulator to which a \ref mg_resolver_fn appends the candidate +/// "host:port" targets that an advertised address resolves to. +typedef struct mg_resolver_result mg_resolver_result; + +/// Appends a candidate "host:port" \p target to \p result. The string is +/// copied, so the caller need not keep it alive. Targets are tried in the order +/// they are appended. +/// +/// \return 0 on success, or \ref MG_ERROR_OOM if the copy could not be +/// allocated. +MGCLIENT_EXPORT int mg_resolver_result_add(mg_resolver_result *result, + const char *target); + +/// Maps an advertised "host:port" address from the routing table to zero or +/// more reachable "host:port" targets. +/// +/// The resolver appends each target (in the order to try them) to \p result +/// using \ref mg_resolver_result_add. \p resolver_data is the pointer supplied +/// to \ref mg_router_config_set_resolver. This is the hook to use when the +/// addresses a cluster advertises are not directly reachable by the client (for +/// example behind a proxy or a port-forward). +/// +/// \return 0 on success, or a non-zero value to signal that resolution failed. +typedef int (*mg_resolver_fn)(const char *advertised, + mg_resolver_result *result, void *resolver_data); + +/// Configuration for a \ref mg_router. Opaque; created with +/// \ref mg_router_config_make, populated with the setters below, and passed to +/// \ref mg_router_make (which copies what it needs, so the config and anything +/// it references may be destroyed afterwards). +typedef struct mg_router_config mg_router_config; + +/// Creates a \ref mg_router_config with default settings, or NULL on allocation +/// failure. Free it with \ref mg_router_config_destroy. +MGCLIENT_EXPORT mg_router_config *mg_router_config_make(void); + +/// Destroys a \ref mg_router_config. +MGCLIENT_EXPORT void mg_router_config_destroy(mg_router_config *config); + +/// Sets the connection template used for every connection the router opens, to +/// coordinators and data instances alike (required). +/// +/// The host/port in \p params identify the seed coordinator; for each routed +/// connection the router substitutes the resolved host/port and reuses the +/// remaining options (username, password, SSL settings, client name). The +/// router deep-copies the parameters (including their strings), so \p params +/// and its strings may be freed after \ref mg_router_make. Any +/// ``trust_callback``/``trust_data`` are borrowed and must outlive the router. +MGCLIENT_EXPORT void mg_router_config_set_session_params( + mg_router_config *config, const mg_session_params *params); + +/// Sets an optional address \p resolver and an opaque \p resolver_data pointer +/// passed back to it. If unset, advertised addresses are used unchanged. The +/// resolver and its data are borrowed and must outlive the router. +MGCLIENT_EXPORT void mg_router_config_set_resolver(mg_router_config *config, + mg_resolver_fn resolver, + void *resolver_data); + +/// Sets an optional routing context forwarded to the ROUTE request (see +/// \ref mg_session_route). Copied; NULL (the default) means an empty context. +MGCLIENT_EXPORT void mg_router_config_set_routing_context( + mg_router_config *config, const mg_map *routing_context); + +/// A client-side routing engine for a Memgraph high-availability cluster. +/// +/// A \ref mg_router is created against a seed coordinator and is meant to be +/// long-lived and reused. It fetches the cluster routing table (via a Bolt +/// ROUTE message), caches it until its TTL expires, and hands out ordinary +/// \ref mg_session objects bound to the appropriate data instance. +/// +/// A \ref mg_router is NOT thread-safe: like a \ref mg_session it must be used +/// by a single thread at a time (use one router per thread). The sessions it +/// returns are ordinary sessions owned by the caller. +typedef struct mg_router mg_router; + +/// Creates a \ref mg_router from \p config. Opens no connection. Copies what it +/// needs, so \p config may be destroyed afterwards. +/// +/// \return A freshly allocated \ref mg_router (ownership transferred to the +/// caller, who must call \ref mg_router_destroy), or NULL if \p config +/// is NULL, has no session params set, or an allocation failed. +MGCLIENT_EXPORT mg_router *mg_router_make(const mg_router_config *config); + +/// Destroys a \ref mg_router. +MGCLIENT_EXPORT void mg_router_destroy(mg_router *router); + +/// Opens a connection to a server that serves reads (a replica). +/// +/// On success returns 0 and stores a ready session (owned by the caller, who +/// must call \ref mg_session_destroy) in \p session. On failure returns a +/// non-zero \ref MG_ERROR_ code, stores NULL in \p session, and leaves a +/// message retrievable via \ref mg_router_error. The cached routing table is +/// used (refreshing it if it is missing or expired), the role's replicas are +/// tried in round-robin order, and if all are unreachable the table is +/// refreshed once and the attempt retried. A failover condition (no reachable +/// replica) yields a transient-classified code (see \ref mg_error_is_transient). +MGCLIENT_EXPORT int mg_router_connect_read(mg_router *router, + mg_session **session); + +/// Opens a connection to the server that serves writes (the main). Behaves like +/// \ref mg_router_connect_read but targets the WRITE role. +MGCLIENT_EXPORT int mg_router_connect_write(mg_router *router, + mg_session **session); + +/// Forces an immediate refresh of the cached routing table, contacting the seed +/// coordinator (falling back to the ROUTE-role coordinators from the cached +/// table). Returns 0, or a non-zero \ref MG_ERROR_ code with a message in +/// \ref mg_router_error. +MGCLIENT_EXPORT int mg_router_refresh(mg_router *router); + +/// Returns the router's currently cached routing table, or NULL if none has +/// been fetched yet. The table is borrowed and owned by the router: it is +/// invalidated by the next refresh (including one triggered internally by a +/// connect) or by \ref mg_router_destroy. This does NOT itself trigger a +/// refresh -- call \ref mg_router_refresh first if you need current data. +MGCLIENT_EXPORT const mg_routing_table *mg_router_routing_table( + mg_router *router); + +/// Returns the message describing the last error on \p router, or an empty +/// string if there has been none. The string is owned by the router and valid +/// until the next call on it. +MGCLIENT_EXPORT const char *mg_router_error(mg_router *router); + /// Starts an Explicit transaction on the server. /// /// Every run will be part of that transaction until its explicitly ended. diff --git a/src/mgrouting.c b/src/mgrouting.c index 79aab6b..8e6e857 100644 --- a/src/mgrouting.c +++ b/src/mgrouting.c @@ -13,19 +13,15 @@ // limitations under the License. #include "mgclient.h" +#include "mgrouting.h" #include #include #include +#include #include #include - -// A growable array of owned, NUL-terminated address strings. -typedef struct { - char **items; - uint32_t size; - uint32_t capacity; -} mg_addr_list; +#include struct mg_routing_table { int64_t ttl; @@ -223,3 +219,500 @@ int mg_error_is_committed_on_main(const char *message) { return contains_ci(message, "replication exception") && contains_ci(message, "committed on the main"); } + +// --------------------------------------------------------------------------- +// Router configuration and lifecycle. +// --------------------------------------------------------------------------- + +struct mg_router_config { + const mg_session_params *session_params; // borrowed; deep-copied by _make. + mg_resolver_fn resolver; + void *resolver_data; + mg_map *routing_context; // owned copy, or NULL. +}; + +struct mg_router { + // Connection template, deep-copied from the seed session params so the caller + // may free the params after mg_router_make. Exactly one of seed_host / + // seed_address is set (the other is NULL), mirroring mg_session_params. + char *seed_host; + char *seed_address; + uint16_t seed_port; + char *username; + char *password; + char *user_agent; + enum mg_sslmode sslmode; + char *sslcert; + char *sslkey; + mg_trust_callback_type trust_callback; // borrowed + void *trust_data; // borrowed + + mg_resolver_fn resolver; // borrowed; NULL means identity. + void *resolver_data; // borrowed + mg_map *routing_context; // owned copy, or NULL. + + // Cached routing table (populated by refresh) and when it expires (seconds, + // wall clock). expires_at == 0 while no table is cached. + mg_routing_table *table; + time_t expires_at; + // Round-robin cursor for READ selection. + uint32_t read_index; + + char error[1024]; +}; + +// strdup that treats NULL as "not set" (returns NULL). Sets *oom on failure. +static char *dup_or_null(const char *str, int *oom) { + if (!str) { + return NULL; + } + size_t size = strlen(str) + 1; + char *copy = (char *)malloc(size); + if (!copy) { + *oom = 1; + return NULL; + } + memcpy(copy, str, size); + return copy; +} + +mg_router_config *mg_router_config_make(void) { + return (mg_router_config *)calloc(1, sizeof(mg_router_config)); +} + +void mg_router_config_destroy(mg_router_config *config) { + if (!config) { + return; + } + mg_map_destroy(config->routing_context); + free(config); +} + +void mg_router_config_set_session_params(mg_router_config *config, + const mg_session_params *params) { + config->session_params = params; +} + +void mg_router_config_set_resolver(mg_router_config *config, + mg_resolver_fn resolver, + void *resolver_data) { + config->resolver = resolver; + config->resolver_data = resolver_data; +} + +void mg_router_config_set_routing_context(mg_router_config *config, + const mg_map *routing_context) { + mg_map_destroy(config->routing_context); + config->routing_context = + routing_context ? mg_map_copy(routing_context) : NULL; +} + +mg_router *mg_router_make(const mg_router_config *config) { + if (!config || !config->session_params) { + return NULL; + } + mg_router *router = (mg_router *)calloc(1, sizeof(mg_router)); + if (!router) { + return NULL; + } + const mg_session_params *params = config->session_params; + + router->seed_port = mg_session_params_get_port(params); + router->sslmode = mg_session_params_get_sslmode(params); + router->trust_callback = mg_session_params_get_trust_callback(params); + router->trust_data = mg_session_params_get_trust_data(params); + router->resolver = config->resolver; + router->resolver_data = config->resolver_data; + + int oom = 0; + router->seed_host = dup_or_null(mg_session_params_get_host(params), &oom); + router->seed_address = + dup_or_null(mg_session_params_get_address(params), &oom); + router->username = dup_or_null(mg_session_params_get_username(params), &oom); + router->password = dup_or_null(mg_session_params_get_password(params), &oom); + router->user_agent = + dup_or_null(mg_session_params_get_user_agent(params), &oom); + router->sslcert = dup_or_null(mg_session_params_get_sslcert(params), &oom); + router->sslkey = dup_or_null(mg_session_params_get_sslkey(params), &oom); + + if (config->routing_context) { + router->routing_context = mg_map_copy(config->routing_context); + if (!router->routing_context) { + oom = 1; + } + } + + if (oom) { + mg_router_destroy(router); + return NULL; + } + return router; +} + +void mg_router_destroy(mg_router *router) { + if (!router) { + return; + } + free(router->seed_host); + free(router->seed_address); + free(router->username); + free(router->password); + free(router->user_agent); + free(router->sslcert); + free(router->sslkey); + mg_map_destroy(router->routing_context); + mg_routing_table_destroy(router->table); + free(router); +} + +// --------------------------------------------------------------------------- +// Refresh: fetch, parse and cache the routing table (with coordinator failover). +// --------------------------------------------------------------------------- + +struct mg_resolver_result { + mg_addr_list list; +}; + +int mg_resolver_result_add(mg_resolver_result *result, const char *target) { + if (!result || !target) { + return MG_ERROR_BAD_PARAMETER; + } + return addr_list_append(&result->list, target, (uint32_t)strlen(target)) == 0 + ? 0 + : MG_ERROR_OOM; +} + +void mg_addr_list_clear(mg_addr_list *list) { + for (uint32_t i = 0; i < list->size; ++i) { + free(list->items[i]); + } + free(list->items); + list->items = NULL; + list->size = 0; + list->capacity = 0; +} + +// Resolve an advertised "host:port" into candidate targets, applying the +// router's resolver, or the identity mapping if none is set. +static int resolve_address(mg_router *router, const char *advertised, + mg_resolver_result *result) { + if (router->resolver) { + return router->resolver(advertised, result, router->resolver_data); + } + return mg_resolver_result_add(result, advertised); +} + +// Split "host:port" (on the last ':') into a freshly allocated host and a port. +// Returns 0 on success. +static int split_host_port(const char *address, char **host_out, + uint16_t *port_out) { + const char *colon = strrchr(address, ':'); + if (!colon || colon == address || colon[1] == '\0') { + return -1; + } + char *end = NULL; + long port = strtol(colon + 1, &end, 10); + if (*end != '\0' || port < 0 || port > 65535) { + return -1; + } + size_t host_len = (size_t)(colon - address); + char *host = (char *)malloc(host_len + 1); + if (!host) { + return -1; + } + memcpy(host, address, host_len); + host[host_len] = '\0'; + *host_out = host; + *port_out = (uint16_t)port; + return 0; +} + +static void router_set_error(mg_router *router, const char *message) { + snprintf(router->error, sizeof(router->error), "%s", + message ? message : ""); +} + +// Open a connection using the router's connection template, directed at +// host/port. On failure returns NULL, stores the message in the router, and +// sets *status_out. +static mg_session *router_connect_to(mg_router *router, const char *host, + uint16_t port, int use_address, + int *status_out) { + mg_session_params *params = mg_session_params_make(); + if (!params) { + router_set_error(router, "couldn't allocate session parameters"); + *status_out = MG_ERROR_OOM; + return NULL; + } + if (use_address) { + mg_session_params_set_address(params, host); + } else { + mg_session_params_set_host(params, host); + } + mg_session_params_set_port(params, port); + mg_session_params_set_username(params, router->username); + mg_session_params_set_password(params, router->password); + if (router->user_agent) { + mg_session_params_set_user_agent(params, router->user_agent); + } + mg_session_params_set_sslmode(params, router->sslmode); + mg_session_params_set_sslcert(params, router->sslcert); + mg_session_params_set_sslkey(params, router->sslkey); + if (router->trust_callback) { + mg_session_params_set_trust_callback(params, router->trust_callback); + mg_session_params_set_trust_data(params, router->trust_data); + } + + mg_session *session = NULL; + int status = mg_connect(params, &session); + mg_session_params_destroy(params); + if (status != 0) { + router_set_error(router, mg_session_error(session)); + mg_session_destroy(session); + *status_out = status; + return NULL; + } + *status_out = 0; + return session; +} + +// Send ROUTE on an established coordinator session, parse the result, and cache +// it (replacing any previous table and resetting the TTL). Returns 0 on success. +static int refresh_from_session(mg_router *router, mg_session *session) { + mg_map *empty_context = NULL; + const mg_map *routing = router->routing_context; + if (!routing) { + empty_context = mg_map_make_empty(0); + if (!empty_context) { + router_set_error(router, "couldn't allocate routing context"); + return MG_ERROR_OOM; + } + routing = empty_context; + } + + mg_map *raw = NULL; + int status = mg_session_route(session, routing, NULL, NULL, &raw); + mg_map_destroy(empty_context); + if (status != 0) { + router_set_error(router, mg_session_error(session)); + return status; + } + + mg_routing_table *table = mg_routing_table_parse(raw); + mg_map_destroy(raw); + if (!table) { + router_set_error(router, "couldn't parse routing table"); + return MG_ERROR_OOM; + } + + mg_routing_table_destroy(router->table); + router->table = table; + router->expires_at = time(NULL) + (time_t)mg_routing_table_ttl(table); + return 0; +} + +int mg_router_refresh(mg_router *router) { + router->error[0] = '\0'; + int last_status = MG_ERROR_TRANSIENT_ERROR; + + // 1) The seed coordinator (used as given, not resolved). + { + const char *seed_host = + router->seed_host ? router->seed_host : router->seed_address; + int use_address = router->seed_host == NULL; + int status = 0; + mg_session *session = router_connect_to(router, seed_host, + router->seed_port, use_address, + &status); + if (session) { + status = refresh_from_session(router, session); + mg_session_destroy(session); + if (status == 0) { + return 0; + } + } + last_status = status; + } + + // 2) Fall back to the ROUTE-role coordinators from the cached table (if any), + // resolved to reachable targets. + if (router->table) { + uint32_t count = + mg_routing_table_address_count(router->table, MG_ROUTING_ROLE_ROUTE); + for (uint32_t i = 0; i < count; ++i) { + const char *advertised = + mg_routing_table_address_at(router->table, MG_ROUTING_ROLE_ROUTE, i); + mg_resolver_result result; + memset(&result, 0, sizeof(result)); + if (resolve_address(router, advertised, &result) != 0) { + mg_addr_list_clear(&result.list); + continue; + } + for (uint32_t j = 0; j < result.list.size; ++j) { + char *host = NULL; + uint16_t port = 0; + if (split_host_port(result.list.items[j], &host, &port) != 0) { + continue; + } + int status = 0; + mg_session *session = router_connect_to(router, host, port, 0, &status); + free(host); + if (session) { + status = refresh_from_session(router, session); + mg_session_destroy(session); + if (status == 0) { + mg_addr_list_clear(&result.list); + return 0; + } + } + last_status = status; + } + mg_addr_list_clear(&result.list); + } + } + + if (router->error[0] == '\0') { + router_set_error(router, + "could not refresh routing table from any coordinator"); + } + return last_status; +} + +const mg_routing_table *mg_router_routing_table(mg_router *router) { + return router ? router->table : NULL; +} + +const char *mg_router_error(mg_router *router) { + return router ? router->error : ""; +} + +// --------------------------------------------------------------------------- +// Connect: select a server for the access mode (round-robin for READ), resolve, +// and fail over across candidates, refreshing the table once on exhaustion. +// --------------------------------------------------------------------------- + +static int addr_list_contains(const mg_addr_list *list, const char *value) { + for (uint32_t i = 0; i < list->size; ++i) { + if (strcmp(list->items[i], value) == 0) { + return 1; + } + } + return 0; +} + +int mg_routing_select_targets(const mg_routing_table *table, + enum mg_routing_role role, + mg_resolver_fn resolver, void *resolver_data, + uint32_t *read_index, mg_addr_list *out) { + if (!table) { + return 0; + } + uint32_t count = mg_routing_table_address_count(table, role); + if (count == 0) { + return 0; + } + uint32_t start = 0; + if (role == MG_ROUTING_ROLE_READ && read_index) { + start = *read_index % count; + *read_index += 1; + } + for (uint32_t k = 0; k < count; ++k) { + const char *advertised = + mg_routing_table_address_at(table, role, (start + k) % count); + mg_resolver_result result; + memset(&result, 0, sizeof(result)); + int rc = resolver ? resolver(advertised, &result, resolver_data) + : mg_resolver_result_add(&result, advertised); + if (rc == 0) { + for (uint32_t j = 0; j < result.list.size; ++j) { + const char *target = result.list.items[j]; + if (!addr_list_contains(out, target) && + addr_list_append(out, target, (uint32_t)strlen(target)) != 0) { + mg_addr_list_clear(&result.list); + return MG_ERROR_OOM; + } + } + } + mg_addr_list_clear(&result.list); + } + return 0; +} + +static const char *role_name(enum mg_routing_role role) { + return role == MG_ROUTING_ROLE_WRITE ? "WRITE" : "READ"; +} + +static int router_connect_role(mg_router *router, enum mg_routing_role role, + mg_session **session_out) { + if (!router || !session_out) { + return MG_ERROR_BAD_PARAMETER; + } + *session_out = NULL; + router->error[0] = '\0'; + int last_status = MG_ERROR_TRANSIENT_ERROR; + + // Two attempts: the second runs against a freshly refreshed table, in case + // the topology changed (e.g. a failover) since it was cached. + for (int attempt = 0; attempt < 2; ++attempt) { + if (!router->table || time(NULL) >= router->expires_at) { + int status = mg_router_refresh(router); + if (status != 0) { + last_status = status; // error already recorded by refresh. + } + } + + mg_addr_list candidates; + memset(&candidates, 0, sizeof(candidates)); + mg_routing_select_targets(router->table, role, router->resolver, + router->resolver_data, &router->read_index, + &candidates); + + if (candidates.size == 0) { + char message[128]; + snprintf(message, sizeof(message), "no %s server in the routing table", + role_name(role)); + router_set_error(router, message); + last_status = MG_ERROR_TRANSIENT_ERROR; + } else { + for (uint32_t i = 0; i < candidates.size; ++i) { + char *host = NULL; + uint16_t port = 0; + if (split_host_port(candidates.items[i], &host, &port) != 0) { + continue; + } + int status = 0; + mg_session *session = router_connect_to(router, host, port, 0, &status); + free(host); + if (session) { + *session_out = session; + mg_addr_list_clear(&candidates); + return 0; + } + last_status = status; + } + } + mg_addr_list_clear(&candidates); + + if (attempt == 0) { + // The selected servers were unreachable (or none were listed); discard + // the cached table and retry against a fresh one. + mg_router_refresh(router); + } + } + + if (router->error[0] == '\0') { + char message[128]; + snprintf(message, sizeof(message), "could not connect to any %s server", + role_name(role)); + router_set_error(router, message); + } + return last_status; +} + +int mg_router_connect_read(mg_router *router, mg_session **session) { + return router_connect_role(router, MG_ROUTING_ROLE_READ, session); +} + +int mg_router_connect_write(mg_router *router, mg_session **session) { + return router_connect_role(router, MG_ROUTING_ROLE_WRITE, session); +} diff --git a/src/mgrouting.h b/src/mgrouting.h new file mode 100644 index 0000000..ec185c0 --- /dev/null +++ b/src/mgrouting.h @@ -0,0 +1,59 @@ +// 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. + +// Internal routing helpers. NOT part of the public API -- exposed only so the +// (white-box) unit tests can drive the pure selection logic without a cluster. + +#ifndef MGCLIENT_MGROUTING_H +#define MGCLIENT_MGROUTING_H + +#ifdef __cplusplus +extern "C" { +#endif + +#include + +#include "mgclient.h" + +// A growable array of owned, NUL-terminated address strings. +typedef struct { + char **items; + uint32_t size; + uint32_t capacity; +} mg_addr_list; + +// Frees the contents of `list` and resets it to empty. +void mg_addr_list_clear(mg_addr_list *list); + +// Fills `out` (which must be zero-initialised) with the resolved candidate +// "host:port" targets for `role`, in the order they should be tried. +// +// For MG_ROUTING_ROLE_READ, selection starts at (*read_index % count) and +// *read_index is post-incremented, giving round-robin across replicas while +// keeping the remaining replicas as failover candidates; WRITE and ROUTE are +// returned in table order and `read_index` is left untouched. Each advertised +// address is mapped through `resolver` (with `resolver_data`), or the identity +// mapping when `resolver` is NULL. Duplicate targets are skipped. +// +// Returns 0 on success, or MG_ERROR_OOM on allocation failure. +int mg_routing_select_targets(const mg_routing_table *table, + enum mg_routing_role role, + mg_resolver_fn resolver, void *resolver_data, + uint32_t *read_index, mg_addr_list *out); + +#ifdef __cplusplus +} +#endif + +#endif // MGCLIENT_MGROUTING_H diff --git a/tests/routing.cpp b/tests/routing.cpp index d18de85..c22de0b 100644 --- a/tests/routing.cpp +++ b/tests/routing.cpp @@ -14,12 +14,63 @@ #include +#include +#include +#include #include #include "mgclient.h" +#include "mgrouting.h" // internal selection helper (white-box test) + +// A resolver (C linkage) that collapses every advertised address to one target, +// used to check resolver application + de-duplication. +extern "C" int CollapseResolver(const char *advertised, + mg_resolver_result *result, void *data) { + (void)advertised; + (void)data; + return mg_resolver_result_add(result, "10.0.0.1:7687"); +} + +// Resolver that remaps advertised addresses per MEMGRAPH_HA_ADDRESS_MAP +// ("adv1=target1,adv2=target2,..."), falling back to identity. Lets the +// cluster-gated tests reach a Kubernetes cluster through kubectl port-forwards. +extern "C" int EnvMapResolver(const char *advertised, mg_resolver_result *result, + void *data) { + (void)data; + const char *map = std::getenv("MEMGRAPH_HA_ADDRESS_MAP"); + if (map) { + const std::string advertised_str(advertised); + const std::string entries(map); + size_t pos = 0; + while (pos <= entries.size()) { + size_t comma = entries.find(',', pos); + std::string pair = entries.substr( + pos, comma == std::string::npos ? std::string::npos : comma - pos); + size_t eq = pair.find('='); + if (eq != std::string::npos && pair.substr(0, eq) == advertised_str) { + return mg_resolver_result_add(result, pair.substr(eq + 1).c_str()); + } + if (comma == std::string::npos) { + break; + } + pos = comma + 1; + } + } + return mg_resolver_result_add(result, advertised); +} namespace { +// Initialises mgclient once for the whole test binary (mg_init sets up the +// process-global state that mg_session_pull and friends rely on). +class MgclientEnvironment : public ::testing::Environment { + public: + void SetUp() override { mg_init(); } + void TearDown() override { mg_finalize(); } +}; +::testing::Environment *const kMgclientEnv = + ::testing::AddGlobalTestEnvironment(new MgclientEnvironment); + // Build a `mg_value` list-of-strings from the given addresses. mg_value *StringList(const std::vector &addrs) { mg_list *list = mg_list_make_empty(static_cast(addrs.size())); @@ -99,6 +150,280 @@ TEST(ErrorClassification, TransientIsFalseForNonTransportFailures) { EXPECT_FALSE(mg_error_is_transient(MG_ERROR_SSL_ERROR)); } +// --------------------------------------------------------------------------- +// Router selection (round-robin) -- pure logic via the internal seam. +// --------------------------------------------------------------------------- + +namespace { +mg_routing_table *ParseTable(int64_t ttl, mg_list *servers) { + mg_map *raw = mg_map_make_empty(2); + mg_map_insert(raw, "ttl", mg_value_make_integer(ttl)); + mg_map_insert(raw, "servers", mg_value_make_list(servers)); + mg_routing_table *table = mg_routing_table_parse(raw); + mg_map_destroy(raw); + return table; +} +} // namespace + +TEST(RouterSelect, ReadRoundRobinsAcrossReplicas) { + mg_list *servers = mg_list_make_empty(2); + mg_list_append(servers, Server({"m:7687"}, "WRITE")); + mg_list_append(servers, Server({"r1:7687", "r2:7687", "r3:7687"}, "READ")); + mg_routing_table *table = ParseTable(300, servers); + ASSERT_NE(table, nullptr); + + uint32_t read_index = 0; + std::vector firsts; + for (int i = 0; i < 4; ++i) { + mg_addr_list out; + memset(&out, 0, sizeof(out)); + ASSERT_EQ(mg_routing_select_targets(table, MG_ROUTING_ROLE_READ, nullptr, + nullptr, &read_index, &out), + 0); + ASSERT_EQ(out.size, 3u); // every replica stays a failover candidate + firsts.emplace_back(out.items[0]); + mg_addr_list_clear(&out); + } + // Each selection starts at the next replica, wrapping around. + EXPECT_EQ(firsts[0], "r1:7687"); + EXPECT_EQ(firsts[1], "r2:7687"); + EXPECT_EQ(firsts[2], "r3:7687"); + EXPECT_EQ(firsts[3], "r1:7687"); + + mg_routing_table_destroy(table); +} + +TEST(RouterSelect, WriteDoesNotRotate) { + mg_list *servers = mg_list_make_empty(2); + mg_list_append(servers, Server({"m:7687"}, "WRITE")); + mg_list_append(servers, Server({"r1:7687", "r2:7687"}, "READ")); + mg_routing_table *table = ParseTable(300, servers); + ASSERT_NE(table, nullptr); + + uint32_t read_index = 5; + mg_addr_list out; + memset(&out, 0, sizeof(out)); + ASSERT_EQ(mg_routing_select_targets(table, MG_ROUTING_ROLE_WRITE, nullptr, + nullptr, &read_index, &out), + 0); + ASSERT_EQ(out.size, 1u); + EXPECT_STREQ(out.items[0], "m:7687"); + EXPECT_EQ(read_index, 5u); // WRITE selection leaves the READ cursor alone + mg_addr_list_clear(&out); + + mg_routing_table_destroy(table); +} + +TEST(RouterSelect, ResolverAppliedAndDuplicatesSkipped) { + mg_list *servers = mg_list_make_empty(1); + mg_list_append(servers, Server({"r1:7687", "r2:7687"}, "READ")); + mg_routing_table *table = ParseTable(300, servers); + ASSERT_NE(table, nullptr); + + uint32_t read_index = 0; + mg_addr_list out; + memset(&out, 0, sizeof(out)); + ASSERT_EQ(mg_routing_select_targets(table, MG_ROUTING_ROLE_READ, + CollapseResolver, nullptr, &read_index, + &out), + 0); + // Both replicas resolve to the same target, so it is listed once. + ASSERT_EQ(out.size, 1u); + EXPECT_STREQ(out.items[0], "10.0.0.1:7687"); + mg_addr_list_clear(&out); + + mg_routing_table_destroy(table); +} + +// --------------------------------------------------------------------------- +// Router config + lifecycle (no cluster needed). +// --------------------------------------------------------------------------- + +namespace { +mg_session_params *SeedParams(const char *host, uint16_t port) { + mg_session_params *params = mg_session_params_make(); + mg_session_params_set_host(params, host); + mg_session_params_set_port(params, port); + return params; +} +} // namespace + +TEST(RouterConfig, MakeAndDestroy) { + mg_router_config *config = mg_router_config_make(); + ASSERT_NE(config, nullptr); + mg_router_config_destroy(config); + mg_router_config_destroy(nullptr); // must be a no-op +} + +TEST(Router, MakeRequiresConfigAndSessionParams) { + EXPECT_EQ(mg_router_make(nullptr), nullptr); + + // A config with no session params set cannot make a router. + mg_router_config *config = mg_router_config_make(); + EXPECT_EQ(mg_router_make(config), nullptr); + mg_router_config_destroy(config); +} + +TEST(Router, MakeCopiesConfigSoItCanBeFreed) { + mg_router_config *config = mg_router_config_make(); + mg_session_params *params = SeedParams("coordinator", 7687); + mg_session_params_set_username(params, "user"); + mg_session_params_set_password(params, "pass"); + mg_router_config_set_session_params(config, params); + + mg_router *router = mg_router_make(config); + ASSERT_NE(router, nullptr); + + // The router copied what it needs, so the params and config may be freed + // now while the router lives on (ASan verifies the deep copy is clean). + mg_session_params_destroy(params); + mg_router_config_destroy(config); + + mg_router_destroy(router); + mg_router_destroy(nullptr); // must be a no-op +} + +namespace { +mg_router *MakeRouter(const char *host, uint16_t port) { + mg_router_config *config = mg_router_config_make(); + mg_session_params *params = SeedParams(host, port); + mg_router_config_set_session_params(config, params); + mg_router *router = mg_router_make(config); + mg_session_params_destroy(params); + mg_router_config_destroy(config); + return router; +} +} // namespace + +TEST(RouterRefresh, AccessorsBeforeAnyRefresh) { + mg_router *router = MakeRouter("127.0.0.1", 7687); + ASSERT_NE(router, nullptr); + EXPECT_EQ(mg_router_routing_table(router), nullptr); + EXPECT_STREQ(mg_router_error(router), ""); + mg_router_destroy(router); +} + +TEST(RouterRefresh, FailsWhenSeedUnreachable) { + // Port 1 has nothing listening, so the refresh can't reach any coordinator. + mg_router *router = MakeRouter("127.0.0.1", 1); + ASSERT_NE(router, nullptr); + + int status = mg_router_refresh(router); + EXPECT_NE(status, 0); + EXPECT_TRUE(mg_error_is_transient(status)); // connection refused is transient + EXPECT_STRNE(mg_router_error(router), ""); // a message was recorded + EXPECT_EQ(mg_router_routing_table(router), nullptr); // nothing cached + + mg_router_destroy(router); +} + +// Runs only against a real HA cluster; set MEMGRAPH_HA_COORDINATOR_HOST (and +// optionally _PORT) to enable it. +TEST(RouterRefresh, FetchesTableFromCoordinator) { + const char *host = std::getenv("MEMGRAPH_HA_COORDINATOR_HOST"); + if (!host) { + GTEST_SKIP() << "set MEMGRAPH_HA_COORDINATOR_HOST to run"; + } + const char *port_str = std::getenv("MEMGRAPH_HA_COORDINATOR_PORT"); + uint16_t port = port_str ? static_cast(std::atoi(port_str)) : 7687; + + mg_router *router = MakeRouter(host, port); + ASSERT_NE(router, nullptr); + + int status = mg_router_refresh(router); + ASSERT_EQ(status, 0) << mg_router_error(router); + + const mg_routing_table *table = mg_router_routing_table(router); + ASSERT_NE(table, nullptr); + EXPECT_GT(mg_routing_table_ttl(table), 0); + EXPECT_GT(mg_routing_table_address_count(table, MG_ROUTING_ROLE_WRITE), 0u); + EXPECT_GT(mg_routing_table_address_count(table, MG_ROUTING_ROLE_READ), 0u); + EXPECT_GT(mg_routing_table_address_count(table, MG_ROUTING_ROLE_ROUTE), 0u); + + mg_router_destroy(router); +} + +namespace { +std::string ReplicationRole(mg_session *session) { + std::string role; + if (mg_session_run(session, "SHOW REPLICATION ROLE", nullptr, nullptr, + nullptr, nullptr) != 0 || + mg_session_pull(session, nullptr) != 0) { + return role; + } + mg_result *result = nullptr; + while (mg_session_fetch(session, &result) == 1) { + const mg_list *row = mg_result_row(result); + if (row && mg_list_size(row) > 0) { + const mg_value *value = mg_list_at(row, 0); + if (value && mg_value_get_type(value) == MG_VALUE_TYPE_STRING) { + const mg_string *str = mg_value_string(value); + role.assign(mg_string_data(str), mg_string_size(str)); + } + } + } + return role; +} + +mg_router *MakeRouterWithResolver(const char *host, uint16_t port, + mg_resolver_fn resolver) { + mg_router_config *config = mg_router_config_make(); + mg_session_params *params = SeedParams(host, port); + mg_router_config_set_session_params(config, params); + mg_router_config_set_resolver(config, resolver, nullptr); + mg_router *router = mg_router_make(config); + mg_session_params_destroy(params); + mg_router_config_destroy(config); + return router; +} + +// Returns the coordinator port from MEMGRAPH_HA_COORDINATOR_PORT (default 7687). +uint16_t CoordinatorPort() { + const char *port_str = std::getenv("MEMGRAPH_HA_COORDINATOR_PORT"); + return port_str ? static_cast(std::atoi(port_str)) : 7687; +} +} // namespace + +// Cluster-gated: set MEMGRAPH_HA_COORDINATOR_HOST (and MEMGRAPH_HA_ADDRESS_MAP +// if the advertised addresses are not directly reachable) to run these. +TEST(RouterConnect, WriteReachesMain) { + const char *host = std::getenv("MEMGRAPH_HA_COORDINATOR_HOST"); + if (!host) { + GTEST_SKIP() << "set MEMGRAPH_HA_COORDINATOR_HOST to run"; + } + mg_router *router = MakeRouterWithResolver(host, CoordinatorPort(), + EnvMapResolver); + ASSERT_NE(router, nullptr); + + mg_session *session = nullptr; + int status = mg_router_connect_write(router, &session); + ASSERT_EQ(status, 0) << mg_router_error(router); + ASSERT_NE(session, nullptr); + EXPECT_EQ(ReplicationRole(session), "main"); + + mg_session_destroy(session); + mg_router_destroy(router); +} + +TEST(RouterConnect, ReadReachesReplica) { + const char *host = std::getenv("MEMGRAPH_HA_COORDINATOR_HOST"); + if (!host) { + GTEST_SKIP() << "set MEMGRAPH_HA_COORDINATOR_HOST to run"; + } + mg_router *router = MakeRouterWithResolver(host, CoordinatorPort(), + EnvMapResolver); + ASSERT_NE(router, nullptr); + + mg_session *session = nullptr; + int status = mg_router_connect_read(router, &session); + ASSERT_EQ(status, 0) << mg_router_error(router); + ASSERT_NE(session, nullptr); + EXPECT_EQ(ReplicationRole(session), "replica"); + + mg_session_destroy(session); + mg_router_destroy(router); +} + TEST(ErrorClassification, CommittedOnMainNeedsBothMarkers) { const char *committed = "Replication Exception: Failed to replicate to SYNC replica 'instance_1': " From 3476f0266c192353ea67c224a384cb0f16f34799 Mon Sep 17 00:00:00 2001 From: matt Date: Thu, 9 Jul 2026 11:05:43 +0100 Subject: [PATCH 04/20] managed transactions --- include/mgclient.h | 61 +++++++++++++++++ src/mgrouting.c | 165 ++++++++++++++++++++++++++++++++++++++++++++- src/mgrouting.h | 6 ++ tests/routing.cpp | 153 +++++++++++++++++++++++++++++++++++++++++ 4 files changed, 384 insertions(+), 1 deletion(-) diff --git a/include/mgclient.h b/include/mgclient.h index c204297..7e88637 100644 --- a/include/mgclient.h +++ b/include/mgclient.h @@ -1557,6 +1557,22 @@ MGCLIENT_EXPORT void mg_router_config_set_resolver(mg_router_config *config, MGCLIENT_EXPORT void mg_router_config_set_routing_context( mg_router_config *config, const mg_map *routing_context); +/// Sets the maximum number of attempts a managed transaction +/// (\ref mg_router_execute_read / \ref mg_router_execute_write) makes before +/// giving up on a transient failure. A value of 1 disables retries. The +/// default is 8. +MGCLIENT_EXPORT void mg_router_config_set_max_retries(mg_router_config *config, + uint32_t max_retries); + +/// Sets the capped-exponential backoff used between managed-transaction +/// attempts. The delay before the retry following attempt N (1-based) is +/// ``min(base_seconds * 2^(N-1), cap_seconds)``. The defaults are +/// ``base_seconds = 1.0`` and ``cap_seconds = 15.0``. A ``base_seconds`` of 0 +/// disables waiting between attempts. +MGCLIENT_EXPORT void mg_router_config_set_retry_backoff(mg_router_config *config, + double base_seconds, + double cap_seconds); + /// A client-side routing engine for a Memgraph high-availability cluster. /// /// A \ref mg_router is created against a seed coordinator and is meant to be @@ -1617,6 +1633,51 @@ MGCLIENT_EXPORT const mg_routing_table *mg_router_routing_table( /// until the next call on it. MGCLIENT_EXPORT const char *mg_router_error(mg_router *router); +/// A unit of work run by \ref mg_router_execute_read / \ref +/// mg_router_execute_write against a routed \p session. +/// +/// The callback issues its query or queries with \ref mg_session_run / +/// \ref mg_session_pull / \ref mg_session_fetch and stashes whatever the caller +/// needs through \p work_data (the opaque pointer passed to execute). For a +/// write, it must NOT begin/commit/rollback the transaction itself -- execute +/// owns the transaction boundary. +/// +/// \return 0 on success, or the failing \ref MG_ERROR_ code (typically the one +/// returned by the session call that failed). The router uses this code +/// to decide whether the failure is transient and worth retrying (see +/// \ref mg_error_is_transient). +/// +/// The callback may be invoked more than once (on retry), so it should be free +/// of side effects other than the database operations themselves. +typedef int (*mg_work_fn)(mg_session *session, void *work_data); + +/// Runs \p work as a managed read against a server that serves reads (a +/// replica). +/// +/// Opens a routed READ connection, invokes ``work(session, work_data)``, and +/// closes the connection. On a transient failure -- while connecting, or the +/// code returned by \p work -- the routing table is refreshed and the whole +/// unit is retried with capped-exponential backoff, up to the configured +/// ``max_retries`` (see \ref mg_router_config_set_max_retries and +/// \ref mg_router_config_set_retry_backoff). +/// +/// \return 0 if \p work succeeded, otherwise the last non-zero \ref MG_ERROR_ +/// code (after the retry budget is exhausted, or immediately for a +/// non-transient failure), with a message in \ref mg_router_error. +MGCLIENT_EXPORT int mg_router_execute_read(mg_router *router, mg_work_fn work, + void *work_data); + +/// Runs \p work as a managed write against the server that serves writes (the +/// main). +/// +/// Like \ref mg_router_execute_read, but routed to the main and wrapped in an +/// explicit transaction that execute begins before \p work and commits after +/// it. A write that committed on the main but could not reach a SYNC replica is +/// durable, so it is treated as success and NOT retried (a retry would +/// duplicate the write); see \ref mg_error_is_committed_on_main. +MGCLIENT_EXPORT int mg_router_execute_write(mg_router *router, mg_work_fn work, + void *work_data); + /// Starts an Explicit transaction on the server. /// /// Every run will be part of that transaction until its explicitly ended. diff --git a/src/mgrouting.c b/src/mgrouting.c index 8e6e857..76a5062 100644 --- a/src/mgrouting.c +++ b/src/mgrouting.c @@ -229,8 +229,18 @@ struct mg_router_config { mg_resolver_fn resolver; void *resolver_data; mg_map *routing_context; // owned copy, or NULL. + // Managed-transaction retry policy (see mg_router_execute_*). + uint32_t max_retries; + double retry_backoff; + double retry_backoff_cap; }; +// Defaults for the managed-transaction retry policy, applied by +// mg_router_config_make and overridable via the setters. +#define MG_DEFAULT_MAX_RETRIES 8 +#define MG_DEFAULT_RETRY_BACKOFF 1.0 +#define MG_DEFAULT_RETRY_BACKOFF_CAP 15.0 + struct mg_router { // Connection template, deep-copied from the seed session params so the caller // may free the params after mg_router_make. Exactly one of seed_host / @@ -258,6 +268,11 @@ struct mg_router { // Round-robin cursor for READ selection. uint32_t read_index; + // Managed-transaction retry policy (copied from the config). + uint32_t max_retries; + double retry_backoff; + double retry_backoff_cap; + char error[1024]; }; @@ -277,7 +292,15 @@ static char *dup_or_null(const char *str, int *oom) { } mg_router_config *mg_router_config_make(void) { - return (mg_router_config *)calloc(1, sizeof(mg_router_config)); + mg_router_config *config = + (mg_router_config *)calloc(1, sizeof(mg_router_config)); + if (!config) { + return NULL; + } + config->max_retries = MG_DEFAULT_MAX_RETRIES; + config->retry_backoff = MG_DEFAULT_RETRY_BACKOFF; + config->retry_backoff_cap = MG_DEFAULT_RETRY_BACKOFF_CAP; + return config; } void mg_router_config_destroy(mg_router_config *config) { @@ -307,6 +330,18 @@ void mg_router_config_set_routing_context(mg_router_config *config, routing_context ? mg_map_copy(routing_context) : NULL; } +void mg_router_config_set_max_retries(mg_router_config *config, + uint32_t max_retries) { + config->max_retries = max_retries; +} + +void mg_router_config_set_retry_backoff(mg_router_config *config, + double base_seconds, + double cap_seconds) { + config->retry_backoff = base_seconds; + config->retry_backoff_cap = cap_seconds; +} + mg_router *mg_router_make(const mg_router_config *config) { if (!config || !config->session_params) { return NULL; @@ -323,6 +358,9 @@ mg_router *mg_router_make(const mg_router_config *config) { router->trust_data = mg_session_params_get_trust_data(params); router->resolver = config->resolver; router->resolver_data = config->resolver_data; + router->max_retries = config->max_retries; + router->retry_backoff = config->retry_backoff; + router->retry_backoff_cap = config->retry_backoff_cap; int oom = 0; router->seed_host = dup_or_null(mg_session_params_get_host(params), &oom); @@ -716,3 +754,128 @@ int mg_router_connect_read(mg_router *router, mg_session **session) { int mg_router_connect_write(mg_router *router, mg_session **session) { return router_connect_role(router, MG_ROUTING_ROLE_WRITE, session); } + +// --------------------------------------------------------------------------- +// Managed transactions: run a unit of work with retry + capped backoff. +// --------------------------------------------------------------------------- + +#ifdef _WIN32 +#include +#endif + +// Sleep for `seconds` (fractional). No-op for non-positive values. +static void router_sleep_seconds(double seconds) { + if (seconds <= 0.0) { + return; + } +#ifdef _WIN32 + Sleep((DWORD)(seconds * 1000.0)); +#else + struct timespec ts; + ts.tv_sec = (time_t)seconds; + ts.tv_nsec = (long)((seconds - (double)ts.tv_sec) * 1e9); + nanosleep(&ts, NULL); +#endif +} + +double mg_router_backoff_seconds(uint32_t attempt, double base, double cap) { + if (attempt < 1) { + return 0.0; + } + // base * 2^(attempt-1), computed by repeated doubling to avoid pow() (and the + // math library dependency), clamping to `cap` as soon as it is reached. + double delay = base; + for (uint32_t i = 1; i < attempt; ++i) { + if (delay >= cap) { + return cap; + } + delay *= 2.0; + } + return delay > cap ? cap : delay; +} + +// Runs one attempt of `work` on an established `session`. For a write, wraps it +// in an explicit transaction and commits it, treating a committed-on-main +// (SYNC-replica-unreachable) failure as success. Returns 0 on success, else a +// non-zero MG_ERROR_ code with the message copied into the router. +static int router_run_unit(mg_router *router, mg_session *session, int writing, + mg_work_fn work, void *work_data) { + if (writing) { + int status = mg_session_begin_transaction(session, NULL); + if (status != 0) { + router_set_error(router, mg_session_error(session)); + return status; + } + } + + int status = work(session, work_data); + if (status != 0) { + router_set_error(router, mg_session_error(session)); + if (writing) { + mg_result *result = NULL; + mg_session_rollback_transaction(session, &result); // best effort + } + return status; + } + + if (writing) { + mg_result *result = NULL; + status = mg_session_commit_transaction(session, &result); + if (status != 0) { + const char *message = mg_session_error(session); + if (mg_error_is_committed_on_main(message)) { + // The write is durable on the main; a retry would duplicate it, so + // report success even though the SYNC-replica guarantee was not met. + return 0; + } + router_set_error(router, message); + return status; + } + } + return 0; +} + +static int router_execute(mg_router *router, enum mg_routing_role role, + mg_work_fn work, void *work_data) { + if (!router || !work) { + return MG_ERROR_BAD_PARAMETER; + } + router->error[0] = '\0'; + int writing = (role == MG_ROUTING_ROLE_WRITE); + // At least one attempt, even if max_retries was set to 0. + uint32_t max_attempts = router->max_retries > 0 ? router->max_retries : 1; + int last_status = MG_ERROR_TRANSIENT_ERROR; + + for (uint32_t attempt = 1; attempt <= max_attempts; ++attempt) { + mg_session *session = NULL; + int status = router_connect_role(router, role, &session); + if (status == 0) { + status = router_run_unit(router, session, writing, work, work_data); + mg_session_destroy(session); + if (status == 0) { + return 0; + } + } + // Either connect or the unit failed; the message is already in the router. + last_status = status; + + if (attempt == max_attempts || !mg_error_is_transient(last_status)) { + break; + } + // Transient: force a routing refresh on the next attempt (so it re-routes + // to the new main after a failover) and back off first. + router->expires_at = 0; + router_sleep_seconds(mg_router_backoff_seconds( + attempt, router->retry_backoff, router->retry_backoff_cap)); + } + return last_status; +} + +int mg_router_execute_read(mg_router *router, mg_work_fn work, void *work_data) { + return router_execute(router, MG_ROUTING_ROLE_READ, work, work_data); +} + +int mg_router_execute_write(mg_router *router, mg_work_fn work, + void *work_data) { + return router_execute(router, MG_ROUTING_ROLE_WRITE, work, work_data); +} diff --git a/src/mgrouting.h b/src/mgrouting.h index ec185c0..b446ac3 100644 --- a/src/mgrouting.h +++ b/src/mgrouting.h @@ -52,6 +52,12 @@ int mg_routing_select_targets(const mg_routing_table *table, mg_resolver_fn resolver, void *resolver_data, uint32_t *read_index, mg_addr_list *out); +// The capped-exponential backoff delay (in seconds) to wait before the retry +// that follows attempt `attempt` (1-based): min(base * 2^(attempt-1), cap). +// Returns 0 for attempt < 1. Exposed for white-box testing of the retry policy +// used by mg_router_execute_read / mg_router_execute_write. +double mg_router_backoff_seconds(uint32_t attempt, double base, double cap); + #ifdef __cplusplus } #endif diff --git a/tests/routing.cpp b/tests/routing.cpp index c22de0b..a3fd376 100644 --- a/tests/routing.cpp +++ b/tests/routing.cpp @@ -59,6 +59,64 @@ extern "C" int EnvMapResolver(const char *advertised, mg_resolver_result *result return mg_resolver_result_add(result, advertised); } +// A unit of work (C linkage) that just counts how many times it is invoked, so +// tests can assert whether the router ever reached the work stage. +extern "C" int CountingWork(mg_session *session, void *data) { + (void)session; + ++*static_cast(data); + return 0; +} + +// Drains any pending result stream on `session`. Returns 0 on success, or the +// negative status if a fetch failed. +static int DrainResults(mg_session *session) { + mg_result *result = nullptr; + int fetch; + while ((fetch = mg_session_fetch(session, &result)) == 1) { + } + return fetch < 0 ? fetch : 0; +} + +// A write unit of work: creates and immediately deletes a node (a net no-op +// that still exercises the write path and commit). Must not commit itself -- +// mg_router_execute_write owns the transaction boundary. +extern "C" int WriteNoOpWork(mg_session *session, void *data) { + (void)data; + int status = mg_session_run(session, "CREATE (n:_MgRouterExecuteTest) DELETE n", + nullptr, nullptr, nullptr, nullptr); + if (status != 0) { + return status; + } + if ((status = mg_session_pull(session, nullptr)) != 0) { + return status; + } + return DrainResults(session); +} + +// A read unit of work: runs "RETURN 1" and stores the value through `data`. +extern "C" int ReadReturnsOneWork(mg_session *session, void *data) { + int status = mg_session_run(session, "RETURN 1", nullptr, nullptr, nullptr, + nullptr); + if (status != 0) { + return status; + } + if ((status = mg_session_pull(session, nullptr)) != 0) { + return status; + } + mg_result *result = nullptr; + int fetch; + while ((fetch = mg_session_fetch(session, &result)) == 1) { + const mg_list *row = mg_result_row(result); + if (row && mg_list_size(row) > 0) { + const mg_value *value = mg_list_at(row, 0); + if (value && mg_value_get_type(value) == MG_VALUE_TYPE_INTEGER) { + *static_cast(data) = static_cast(mg_value_integer(value)); + } + } + } + return fetch < 0 ? fetch : 0; +} + namespace { // Initialises mgclient once for the whole test binary (mg_init sets up the @@ -495,6 +553,101 @@ TEST(RoutingTable, ParseIgnoresMalformedEntries) { mg_map_destroy(raw); } +// --------------------------------------------------------------------------- +// Managed transactions: retry policy (pure) + execute lifecycle. +// --------------------------------------------------------------------------- + +TEST(RouterBackoff, CappedExponential) { + // base=1, cap=15: 1, 2, 4, 8, then capped at 15. + EXPECT_DOUBLE_EQ(mg_router_backoff_seconds(1, 1.0, 15.0), 1.0); + EXPECT_DOUBLE_EQ(mg_router_backoff_seconds(2, 1.0, 15.0), 2.0); + EXPECT_DOUBLE_EQ(mg_router_backoff_seconds(3, 1.0, 15.0), 4.0); + EXPECT_DOUBLE_EQ(mg_router_backoff_seconds(4, 1.0, 15.0), 8.0); + EXPECT_DOUBLE_EQ(mg_router_backoff_seconds(5, 1.0, 15.0), 15.0); // capped + EXPECT_DOUBLE_EQ(mg_router_backoff_seconds(9, 1.0, 15.0), 15.0); // stays capped +} + +TEST(RouterBackoff, EdgeCases) { + EXPECT_DOUBLE_EQ(mg_router_backoff_seconds(0, 1.0, 15.0), 0.0); // no attempt 0 + EXPECT_DOUBLE_EQ(mg_router_backoff_seconds(1, 0.0, 15.0), 0.0); // zero base + EXPECT_DOUBLE_EQ(mg_router_backoff_seconds(3, 2.5, 5.0), 5.0); // 2.5,5,capped +} + +namespace { +mg_router *MakeRouterWithRetries(const char *host, uint16_t port, + uint32_t max_retries, double base, double cap) { + mg_router_config *config = mg_router_config_make(); + mg_session_params *params = SeedParams(host, port); + mg_router_config_set_session_params(config, params); + mg_router_config_set_max_retries(config, max_retries); + mg_router_config_set_retry_backoff(config, base, cap); + mg_router *router = mg_router_make(config); + mg_session_params_destroy(params); + mg_router_config_destroy(config); + return router; +} +} // namespace + +TEST(RouterExecute, FailsWhenSeedUnreachable) { + // Nothing listens on port 1, so no READ connection can be established; the + // work must never run, and the transient failure is reported after retries. + // base=cap=0 keeps the test fast (no real sleeping between attempts). + mg_router *router = MakeRouterWithRetries("127.0.0.1", 1, 2, 0.0, 0.0); + ASSERT_NE(router, nullptr); + + int calls = 0; + int status = mg_router_execute_read(router, CountingWork, &calls); + EXPECT_NE(status, 0); + EXPECT_TRUE(mg_error_is_transient(status)); + EXPECT_EQ(calls, 0); // connect never succeeded, so work never ran + EXPECT_STRNE(mg_router_error(router), ""); + + mg_router_destroy(router); +} + +TEST(RouterExecute, RejectsNullWork) { + mg_router *router = MakeRouter("127.0.0.1", 7687); + ASSERT_NE(router, nullptr); + EXPECT_EQ(mg_router_execute_read(router, nullptr, nullptr), + MG_ERROR_BAD_PARAMETER); + EXPECT_EQ(mg_router_execute_write(router, nullptr, nullptr), + MG_ERROR_BAD_PARAMETER); + mg_router_destroy(router); +} + +// Cluster-gated (see the RouterConnect tests above for the env vars). +TEST(RouterExecute, WriteCommits) { + const char *host = std::getenv("MEMGRAPH_HA_COORDINATOR_HOST"); + if (!host) { + GTEST_SKIP() << "set MEMGRAPH_HA_COORDINATOR_HOST to run"; + } + mg_router *router = + MakeRouterWithResolver(host, CoordinatorPort(), EnvMapResolver); + ASSERT_NE(router, nullptr); + + int status = mg_router_execute_write(router, WriteNoOpWork, nullptr); + EXPECT_EQ(status, 0) << mg_router_error(router); + + mg_router_destroy(router); +} + +TEST(RouterExecute, ReadRuns) { + const char *host = std::getenv("MEMGRAPH_HA_COORDINATOR_HOST"); + if (!host) { + GTEST_SKIP() << "set MEMGRAPH_HA_COORDINATOR_HOST to run"; + } + mg_router *router = + MakeRouterWithResolver(host, CoordinatorPort(), EnvMapResolver); + ASSERT_NE(router, nullptr); + + int value = 0; + int status = mg_router_execute_read(router, ReadReturnsOneWork, &value); + ASSERT_EQ(status, 0) << mg_router_error(router); + EXPECT_EQ(value, 1); + + mg_router_destroy(router); +} + TEST(RoutingTable, ParseIsLenientAboutMissingKeys) { // No "ttl" (defaults to 0) and an empty "servers" list yield an empty table, // not NULL. From ab96d43cdf6de034830a8f64241f6c0ae97ea0d7 Mon Sep 17 00:00:00 2001 From: matt Date: Thu, 9 Jul 2026 13:50:13 +0100 Subject: [PATCH 05/20] fix CI build and upgrade gtest --- tests/CMakeLists.txt | 2 +- tests/decoder.cpp | 62 ++++++++++++++++++++++---------------------- tests/encoder.cpp | 26 +++++++++---------- 3 files changed, 45 insertions(+), 45 deletions(-) diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index 3b540d7..ded158e 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -15,7 +15,7 @@ find_package(Threads REQUIRED) include(FetchContent) -set(GTEST_GIT_TAG "release-1.8.1" CACHE STRING "GTest git tag") +set(GTEST_GIT_TAG "v1.17.0" CACHE STRING "GTest git tag") FetchContent_Declare(googletest GIT_REPOSITORY https://github.com/google/googletest.git GIT_TAG ${GTEST_GIT_TAG} diff --git a/tests/decoder.cpp b/tests/decoder.cpp index 0db5a8e..6c80d74 100644 --- a/tests/decoder.cpp +++ b/tests/decoder.cpp @@ -1,4 +1,4 @@ -// Copyright (c) 2016-2020 Memgraph Ltd. [https://memgraph.com] +// 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. @@ -368,63 +368,63 @@ TEST_P(ValueTest, Decoding) { } INSTANTIATE_TEST_CASE_P(Null, ValueTest, - ::testing::ValuesIn(NullTestCases()), ); + ::testing::ValuesIn(NullTestCases())); INSTANTIATE_TEST_CASE_P(Bool, ValueTest, - ::testing::ValuesIn(BoolTestCases()), ); + ::testing::ValuesIn(BoolTestCases())); INSTANTIATE_TEST_CASE_P(Integer, ValueTest, - ::testing::ValuesIn(IntegerTestCases()), ); + ::testing::ValuesIn(IntegerTestCases())); INSTANTIATE_TEST_CASE_P(Float, ValueTest, - ::testing::ValuesIn(FloatTestCases()), ); + ::testing::ValuesIn(FloatTestCases())); INSTANTIATE_TEST_CASE_P(String, ValueTest, - ::testing::ValuesIn(StringTestCases()), ); + ::testing::ValuesIn(StringTestCases())); INSTANTIATE_TEST_CASE_P(List, ValueTest, - ::testing::ValuesIn(ListTestCases()), ); + ::testing::ValuesIn(ListTestCases())); -INSTANTIATE_TEST_CASE_P(Map, ValueTest, ::testing::ValuesIn(MapTestCases()), ); +INSTANTIATE_TEST_CASE_P(Map, ValueTest, ::testing::ValuesIn(MapTestCases())); INSTANTIATE_TEST_CASE_P(Node, ValueTest, - ::testing::ValuesIn(NodeTestCases()), ); + ::testing::ValuesIn(NodeTestCases())); INSTANTIATE_TEST_CASE_P(Relationship, ValueTest, - ::testing::ValuesIn(RelationshipTestCases()), ); + ::testing::ValuesIn(RelationshipTestCases())); INSTANTIATE_TEST_CASE_P(UnboundRelationship, ValueTest, - ::testing::ValuesIn(UnboundRelationshipTestCases()), ); + ::testing::ValuesIn(UnboundRelationshipTestCases())); INSTANTIATE_TEST_CASE_P(Path, ValueTest, - ::testing::ValuesIn(PathTestCases()), ); + ::testing::ValuesIn(PathTestCases())); INSTANTIATE_TEST_CASE_P(Date, ValueTest, - ::testing::ValuesIn(DateTestCases()), ); + ::testing::ValuesIn(DateTestCases())); INSTANTIATE_TEST_CASE_P(Time, ValueTest, - ::testing::ValuesIn(TimeTestCases()), ); + ::testing::ValuesIn(TimeTestCases())); INSTANTIATE_TEST_CASE_P(LocalTime, ValueTest, - ::testing::ValuesIn(LocalTimeTestCases()), ); + ::testing::ValuesIn(LocalTimeTestCases())); INSTANTIATE_TEST_CASE_P(DateTime, ValueTest, - ::testing::ValuesIn(DateTimeTestCases()), ); + ::testing::ValuesIn(DateTimeTestCases())); INSTANTIATE_TEST_CASE_P(DateTimeZoneId, ValueTest, - ::testing::ValuesIn(DateTimeZoneIdTestCases()), ); + ::testing::ValuesIn(DateTimeZoneIdTestCases())); INSTANTIATE_TEST_CASE_P(LocalDateTime, ValueTest, - ::testing::ValuesIn(LocalDateTimeTestCases()), ); + ::testing::ValuesIn(LocalDateTimeTestCases())); INSTANTIATE_TEST_CASE_P(Duration, ValueTest, - ::testing::ValuesIn(DurationTestCases()), ); + ::testing::ValuesIn(DurationTestCases())); INSTANTIATE_TEST_CASE_P(Point2d, ValueTest, - ::testing::ValuesIn(Point2dTestCases()), ); + ::testing::ValuesIn(Point2dTestCases())); INSTANTIATE_TEST_CASE_P(Point3d, ValueTest, - ::testing::ValuesIn(Point3dTestCases()), ); + ::testing::ValuesIn(Point3dTestCases())); // TODO(mtomic): When these tests fail, just a bunch of bytes is outputted, we // might want to make this nicer (maybe add names or descriptions to @@ -463,7 +463,7 @@ TEST_P(IntegerFailure, Test) { INSTANTIATE_TEST_CASE_P( Test, IntegerFailure, ::testing::ValuesIn({""s, "\xC8"s, "\xC9\x01"s, "\xCA\x01\x02\x03"s, - "\xCB\x01\x02\x03\x04\x05\x06\x07"s, "\xCC"s}), ); + "\xCB\x01\x02\x03\x04\x05\x06\x07"s, "\xCC"s})); class BoolFailure : public DecodingFailure {}; @@ -488,7 +488,7 @@ TEST_P(BoolFailure, Test) { } INSTANTIATE_TEST_CASE_P(Test, BoolFailure, - ::testing::ValuesIn({""s, "\xCC"s}), ); + ::testing::ValuesIn({""s, "\xCC"s})); class FloatFailure : public DecodingFailure {}; @@ -514,7 +514,7 @@ TEST_P(FloatFailure, Test) { INSTANTIATE_TEST_CASE_P( Test, FloatFailure, - ::testing::ValuesIn({""s, "\xCC"s, "\xC1\x01\x02\x03\x04\x05\x06\x07"s}), ); + ::testing::ValuesIn({""s, "\xCC"s, "\xC1\x01\x02\x03\x04\x05\x06\x07"s})); class StringFailure : public DecodingFailure {}; @@ -541,7 +541,7 @@ TEST_P(StringFailure, Test) { INSTANTIATE_TEST_CASE_P(Test, StringFailure, ::testing::ValuesIn({""s, "\xCC"s, "\xD0"s, "\xD1\x01"s, "\xD2\x01\x02\x03"s, - "\x85pqrs"s}), ); + "\x85pqrs"s})); class ListFailure : public DecodingFailure {}; @@ -569,7 +569,7 @@ INSTANTIATE_TEST_CASE_P(Test, ListFailure, ::testing::ValuesIn({""s, "\xCC"s, "\xD4"s, "\xD5\x01"s, "\xD6\x01\x02\x03"s, "\x93\x01\x02"s, - "\x93\x01\x02\xCC"s}), ); + "\x93\x01\x02\xCC"s})); class MapFailure : public DecodingFailure {}; @@ -598,7 +598,7 @@ INSTANTIATE_TEST_CASE_P( ::testing::ValuesIn({""s, "\xCC"s, "\xD8"s, "\xD9\x01"s, "\xDA\x01\x02\x03"s, "\xA3\x81x\x01\x81y\xCC\x81z\x03"s, - "\xA3\x81x\x01\x81y\x02\x85z"s}), ); + "\xA3\x81x\x01\x81y\x02\x85z"s})); class NodeFailure : public DecodingFailure {}; @@ -626,7 +626,7 @@ INSTANTIATE_TEST_CASE_P( Test, NodeFailure, ::testing::ValuesIn({""s, "\xB2\x4E"s, "\xB3\x5E"s, "\xB3\x4E"s, "\xB3\x4E\xCC"s, "\xB3\x4E\x01\x95\x82L1\xCC"s, - "\xB3\x4E\x01\x92\x82L1\x82L2\xA2\x81x"s}), ); + "\xB3\x4E\x01\x92\x82L1\x82L2\xA2\x81x"s})); class RelationshipFailure : public DecodingFailure {}; @@ -655,7 +655,7 @@ INSTANTIATE_TEST_CASE_P( ::testing::ValuesIn({""s, "\xB2\x52"s, "\xB5\x02"s, "\xB5\x52"s, "\xB5\x52\xCC"s, "\xB5\x52\x01\xCC"s, "\xB5\x52\x01\x02\xCC"s, "\xB5\x52\x01\x02\x03\xCC"s, - "\xB5\x52\x01\x02\x03\x84type\xCC"s}), ); + "\xB5\x52\x01\x02\x03\x84type\xCC"s})); class UnboundRelationshipFailure : public DecodingFailure {}; @@ -683,7 +683,7 @@ INSTANTIATE_TEST_CASE_P(Test, UnboundRelationshipFailure, ::testing::ValuesIn({""s, "\xB2\x72"s, "\xB3\x02"s, "\xB3\x72"s, "\xB3\x72\xCC"s, "\xB3\x72\x01\xCC"s, - "\xB3\x72\x01\x84type\xCC"s}), ); + "\xB3\x72\x01\x84type\xCC"s})); class PathFailure : public DecodingFailure {}; @@ -717,4 +717,4 @@ INSTANTIATE_TEST_CASE_P( "\xB3\x50\x92\xB3\x4E\x01\x90\xA0\xB3\x4E\x02\x90\xA0\x92\xB3\x72\x01\x84type\xA0\xB3\x72\x02\x84type\xA0\x94"s, "\xB3\x50\x92\xB3\x4E\x01\x90\xA0\xB3\x4E\x02\x90\xA0\x92\xB3\x72\x01\x84type\xA0\xB3\x72\x02\x84type\xA0\x93\x01\x01\x01"s, "\xB3\x50\x92\xB3\x4E\x01\x90\xA0\xB3\x4E\x02\x90\xA0\x92\xB3\x72\x01\x84type\xA0\xB3\x72\x02\x84type\xA0\x94\xF0\x00\x01\x00"s, - "\xB3\x50\x92\xB3\x4E\x01\x90\xA0\xB3\x4E\x02\x90\xA0\x92\xB3\x72\x01\x84type\xA0\xB3\x72\x02\x84type\xA0\x94\x01\x08\x01\x00"s}), ); + "\xB3\x50\x92\xB3\x4E\x01\x90\xA0\xB3\x4E\x02\x90\xA0\x92\xB3\x72\x01\x84type\xA0\xB3\x72\x02\x84type\xA0\x94\x01\x08\x01\x00"s})); diff --git a/tests/encoder.cpp b/tests/encoder.cpp index a45fd2d..605476d 100644 --- a/tests/encoder.cpp +++ b/tests/encoder.cpp @@ -1,4 +1,4 @@ -// Copyright (c) 2016-2020 Memgraph Ltd. [https://memgraph.com] +// 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. @@ -371,36 +371,36 @@ TEST_P(ValueTest, Encoding) { } INSTANTIATE_TEST_CASE_P(Null, ValueTest, - ::testing::ValuesIn(NullTestCases()), ); + ::testing::ValuesIn(NullTestCases())); INSTANTIATE_TEST_CASE_P(Bool, ValueTest, - ::testing::ValuesIn(BoolTestCases()), ); + ::testing::ValuesIn(BoolTestCases())); INSTANTIATE_TEST_CASE_P(Integer, ValueTest, - ::testing::ValuesIn(IntegerTestCases()), ); + ::testing::ValuesIn(IntegerTestCases())); INSTANTIATE_TEST_CASE_P(Float, ValueTest, - ::testing::ValuesIn(FloatTestCases()), ); + ::testing::ValuesIn(FloatTestCases())); INSTANTIATE_TEST_CASE_P(String, ValueTest, - ::testing::ValuesIn(StringTestCases()), ); + ::testing::ValuesIn(StringTestCases())); INSTANTIATE_TEST_CASE_P(List, ValueTest, - ::testing::ValuesIn(ListTestCases()), ); + ::testing::ValuesIn(ListTestCases())); INSTANTIATE_TEST_CASE_P(Date, ValueTest, - ::testing::ValuesIn(DateTestCases()), ); + ::testing::ValuesIn(DateTestCases())); INSTANTIATE_TEST_CASE_P(LocalTime, ValueTest, - ::testing::ValuesIn(LocalTimeTestCases()), ); + ::testing::ValuesIn(LocalTimeTestCases())); INSTANTIATE_TEST_CASE_P(LocalDateTime, ValueTest, - ::testing::ValuesIn(LocalDateTimeTestCases()), ); + ::testing::ValuesIn(LocalDateTimeTestCases())); INSTANTIATE_TEST_CASE_P(DateTime, ValueTest, - ::testing::ValuesIn(DateTimeTestCases()), ); + ::testing::ValuesIn(DateTimeTestCases())); INSTANTIATE_TEST_CASE_P(Duration, ValueTest, - ::testing::ValuesIn((DurationTestCases())), ); + ::testing::ValuesIn((DurationTestCases()))); -INSTANTIATE_TEST_CASE_P(Map, ValueTest, ::testing::ValuesIn(MapTestCases()), ); +INSTANTIATE_TEST_CASE_P(Map, ValueTest, ::testing::ValuesIn(MapTestCases())); From df56d4d639c7b050da31ad35b5ee113e3c17ba5d Mon Sep 17 00:00:00 2001 From: matt Date: Thu, 9 Jul 2026 14:03:57 +0100 Subject: [PATCH 06/20] fix deprecation errors --- include/mgclient.h | 10 +++--- src/mgrouting.c | 24 +++++++-------- tests/decoder.cpp | 77 ++++++++++++++++++++-------------------------- tests/encoder.cpp | 28 ++++++++--------- tests/routing.cpp | 55 +++++++++++++++++++-------------- 5 files changed, 95 insertions(+), 99 deletions(-) diff --git a/include/mgclient.h b/include/mgclient.h index 7e88637..6ad035c 100644 --- a/include/mgclient.h +++ b/include/mgclient.h @@ -1505,7 +1505,7 @@ typedef struct mg_resolver_result mg_resolver_result; /// \return 0 on success, or \ref MG_ERROR_OOM if the copy could not be /// allocated. MGCLIENT_EXPORT int mg_resolver_result_add(mg_resolver_result *result, - const char *target); + const char *target); /// Maps an advertised "host:port" address from the routing table to zero or /// more reachable "host:port" targets. @@ -1569,9 +1569,8 @@ MGCLIENT_EXPORT void mg_router_config_set_max_retries(mg_router_config *config, /// ``min(base_seconds * 2^(N-1), cap_seconds)``. The defaults are /// ``base_seconds = 1.0`` and ``cap_seconds = 15.0``. A ``base_seconds`` of 0 /// disables waiting between attempts. -MGCLIENT_EXPORT void mg_router_config_set_retry_backoff(mg_router_config *config, - double base_seconds, - double cap_seconds); +MGCLIENT_EXPORT void mg_router_config_set_retry_backoff( + mg_router_config *config, double base_seconds, double cap_seconds); /// A client-side routing engine for a Memgraph high-availability cluster. /// @@ -1605,7 +1604,8 @@ MGCLIENT_EXPORT void mg_router_destroy(mg_router *router); /// used (refreshing it if it is missing or expired), the role's replicas are /// tried in round-robin order, and if all are unreachable the table is /// refreshed once and the attempt retried. A failover condition (no reachable -/// replica) yields a transient-classified code (see \ref mg_error_is_transient). +/// replica) yields a transient-classified code (see \ref +/// mg_error_is_transient). MGCLIENT_EXPORT int mg_router_connect_read(mg_router *router, mg_session **session); diff --git a/src/mgrouting.c b/src/mgrouting.c index 76a5062..e0edefd 100644 --- a/src/mgrouting.c +++ b/src/mgrouting.c @@ -12,8 +12,8 @@ // See the License for the specific language governing permissions and // limitations under the License. -#include "mgclient.h" #include "mgrouting.h" +#include "mgclient.h" #include #include @@ -114,8 +114,7 @@ mg_routing_table *mg_routing_table_parse(const mg_map *raw) { } const mg_value *addresses = mg_map_at(server, "addresses"); - if (!addresses || - mg_value_get_type(addresses) != MG_VALUE_TYPE_LIST) { + if (!addresses || mg_value_get_type(addresses) != MG_VALUE_TYPE_LIST) { continue; } const mg_list *address_list = mg_value_list(addresses); @@ -127,7 +126,7 @@ mg_routing_table *mg_routing_table_parse(const mg_map *raw) { } const mg_string *str = mg_value_string(address); if (addr_list_append(&table->roles[role], mg_string_data(str), - mg_string_size(str)) != 0) { + mg_string_size(str)) != 0) { mg_routing_table_destroy(table); return NULL; } @@ -404,7 +403,8 @@ void mg_router_destroy(mg_router *router) { } // --------------------------------------------------------------------------- -// Refresh: fetch, parse and cache the routing table (with coordinator failover). +// Refresh: fetch, parse and cache the routing table (with coordinator +// failover). // --------------------------------------------------------------------------- struct mg_resolver_result { @@ -466,8 +466,7 @@ static int split_host_port(const char *address, char **host_out, } static void router_set_error(mg_router *router, const char *message) { - snprintf(router->error, sizeof(router->error), "%s", - message ? message : ""); + snprintf(router->error, sizeof(router->error), "%s", message ? message : ""); } // Open a connection using the router's connection template, directed at @@ -515,7 +514,8 @@ static mg_session *router_connect_to(mg_router *router, const char *host, } // Send ROUTE on an established coordinator session, parse the result, and cache -// it (replacing any previous table and resetting the TTL). Returns 0 on success. +// it (replacing any previous table and resetting the TTL). Returns 0 on +// success. static int refresh_from_session(mg_router *router, mg_session *session) { mg_map *empty_context = NULL; const mg_map *routing = router->routing_context; @@ -559,9 +559,8 @@ int mg_router_refresh(mg_router *router) { router->seed_host ? router->seed_host : router->seed_address; int use_address = router->seed_host == NULL; int status = 0; - mg_session *session = router_connect_to(router, seed_host, - router->seed_port, use_address, - &status); + mg_session *session = router_connect_to( + router, seed_host, router->seed_port, use_address, &status); if (session) { status = refresh_from_session(router, session); mg_session_destroy(session); @@ -871,7 +870,8 @@ static int router_execute(mg_router *router, enum mg_routing_role role, return last_status; } -int mg_router_execute_read(mg_router *router, mg_work_fn work, void *work_data) { +int mg_router_execute_read(mg_router *router, mg_work_fn work, + void *work_data) { return router_execute(router, MG_ROUTING_ROLE_READ, work, work_data); } diff --git a/tests/decoder.cpp b/tests/decoder.cpp index 6c80d74..3a0d998 100644 --- a/tests/decoder.cpp +++ b/tests/decoder.cpp @@ -367,63 +367,56 @@ TEST_P(ValueTest, Decoding) { ASSERT_MEMORY_OK(); } -INSTANTIATE_TEST_CASE_P(Null, ValueTest, - ::testing::ValuesIn(NullTestCases())); +INSTANTIATE_TEST_SUITE_P(Null, ValueTest, ::testing::ValuesIn(NullTestCases())); -INSTANTIATE_TEST_CASE_P(Bool, ValueTest, - ::testing::ValuesIn(BoolTestCases())); +INSTANTIATE_TEST_SUITE_P(Bool, ValueTest, ::testing::ValuesIn(BoolTestCases())); -INSTANTIATE_TEST_CASE_P(Integer, ValueTest, +INSTANTIATE_TEST_SUITE_P(Integer, ValueTest, ::testing::ValuesIn(IntegerTestCases())); -INSTANTIATE_TEST_CASE_P(Float, ValueTest, +INSTANTIATE_TEST_SUITE_P(Float, ValueTest, ::testing::ValuesIn(FloatTestCases())); -INSTANTIATE_TEST_CASE_P(String, ValueTest, +INSTANTIATE_TEST_SUITE_P(String, ValueTest, ::testing::ValuesIn(StringTestCases())); -INSTANTIATE_TEST_CASE_P(List, ValueTest, - ::testing::ValuesIn(ListTestCases())); +INSTANTIATE_TEST_SUITE_P(List, ValueTest, ::testing::ValuesIn(ListTestCases())); -INSTANTIATE_TEST_CASE_P(Map, ValueTest, ::testing::ValuesIn(MapTestCases())); +INSTANTIATE_TEST_SUITE_P(Map, ValueTest, ::testing::ValuesIn(MapTestCases())); -INSTANTIATE_TEST_CASE_P(Node, ValueTest, - ::testing::ValuesIn(NodeTestCases())); +INSTANTIATE_TEST_SUITE_P(Node, ValueTest, ::testing::ValuesIn(NodeTestCases())); -INSTANTIATE_TEST_CASE_P(Relationship, ValueTest, +INSTANTIATE_TEST_SUITE_P(Relationship, ValueTest, ::testing::ValuesIn(RelationshipTestCases())); -INSTANTIATE_TEST_CASE_P(UnboundRelationship, ValueTest, +INSTANTIATE_TEST_SUITE_P(UnboundRelationship, ValueTest, ::testing::ValuesIn(UnboundRelationshipTestCases())); -INSTANTIATE_TEST_CASE_P(Path, ValueTest, - ::testing::ValuesIn(PathTestCases())); +INSTANTIATE_TEST_SUITE_P(Path, ValueTest, ::testing::ValuesIn(PathTestCases())); -INSTANTIATE_TEST_CASE_P(Date, ValueTest, - ::testing::ValuesIn(DateTestCases())); +INSTANTIATE_TEST_SUITE_P(Date, ValueTest, ::testing::ValuesIn(DateTestCases())); -INSTANTIATE_TEST_CASE_P(Time, ValueTest, - ::testing::ValuesIn(TimeTestCases())); +INSTANTIATE_TEST_SUITE_P(Time, ValueTest, ::testing::ValuesIn(TimeTestCases())); -INSTANTIATE_TEST_CASE_P(LocalTime, ValueTest, +INSTANTIATE_TEST_SUITE_P(LocalTime, ValueTest, ::testing::ValuesIn(LocalTimeTestCases())); -INSTANTIATE_TEST_CASE_P(DateTime, ValueTest, +INSTANTIATE_TEST_SUITE_P(DateTime, ValueTest, ::testing::ValuesIn(DateTimeTestCases())); -INSTANTIATE_TEST_CASE_P(DateTimeZoneId, ValueTest, +INSTANTIATE_TEST_SUITE_P(DateTimeZoneId, ValueTest, ::testing::ValuesIn(DateTimeZoneIdTestCases())); -INSTANTIATE_TEST_CASE_P(LocalDateTime, ValueTest, +INSTANTIATE_TEST_SUITE_P(LocalDateTime, ValueTest, ::testing::ValuesIn(LocalDateTimeTestCases())); -INSTANTIATE_TEST_CASE_P(Duration, ValueTest, +INSTANTIATE_TEST_SUITE_P(Duration, ValueTest, ::testing::ValuesIn(DurationTestCases())); -INSTANTIATE_TEST_CASE_P(Point2d, ValueTest, +INSTANTIATE_TEST_SUITE_P(Point2d, ValueTest, ::testing::ValuesIn(Point2dTestCases())); -INSTANTIATE_TEST_CASE_P(Point3d, ValueTest, +INSTANTIATE_TEST_SUITE_P(Point3d, ValueTest, ::testing::ValuesIn(Point3dTestCases())); // TODO(mtomic): When these tests fail, just a bunch of bytes is outputted, we @@ -460,7 +453,7 @@ TEST_P(IntegerFailure, Test) { ASSERT_MEMORY_OK(); } -INSTANTIATE_TEST_CASE_P( +INSTANTIATE_TEST_SUITE_P( Test, IntegerFailure, ::testing::ValuesIn({""s, "\xC8"s, "\xC9\x01"s, "\xCA\x01\x02\x03"s, "\xCB\x01\x02\x03\x04\x05\x06\x07"s, "\xCC"s})); @@ -487,8 +480,7 @@ TEST_P(BoolFailure, Test) { ASSERT_MEMORY_OK(); } -INSTANTIATE_TEST_CASE_P(Test, BoolFailure, - ::testing::ValuesIn({""s, "\xCC"s})); +INSTANTIATE_TEST_SUITE_P(Test, BoolFailure, ::testing::ValuesIn({""s, "\xCC"s})); class FloatFailure : public DecodingFailure {}; @@ -512,7 +504,7 @@ TEST_P(FloatFailure, Test) { ASSERT_MEMORY_OK(); } -INSTANTIATE_TEST_CASE_P( +INSTANTIATE_TEST_SUITE_P( Test, FloatFailure, ::testing::ValuesIn({""s, "\xCC"s, "\xC1\x01\x02\x03\x04\x05\x06\x07"s})); @@ -538,7 +530,7 @@ TEST_P(StringFailure, Test) { ASSERT_MEMORY_OK(); } -INSTANTIATE_TEST_CASE_P(Test, StringFailure, +INSTANTIATE_TEST_SUITE_P(Test, StringFailure, ::testing::ValuesIn({""s, "\xCC"s, "\xD0"s, "\xD1\x01"s, "\xD2\x01\x02\x03"s, "\x85pqrs"s})); @@ -565,7 +557,7 @@ TEST_P(ListFailure, Test) { ASSERT_MEMORY_OK(); } -INSTANTIATE_TEST_CASE_P(Test, ListFailure, +INSTANTIATE_TEST_SUITE_P(Test, ListFailure, ::testing::ValuesIn({""s, "\xCC"s, "\xD4"s, "\xD5\x01"s, "\xD6\x01\x02\x03"s, "\x93\x01\x02"s, @@ -593,12 +585,11 @@ TEST_P(MapFailure, Test) { ASSERT_MEMORY_OK(); } -INSTANTIATE_TEST_CASE_P( - Test, MapFailure, - ::testing::ValuesIn({""s, "\xCC"s, "\xD8"s, "\xD9\x01"s, - "\xDA\x01\x02\x03"s, - "\xA3\x81x\x01\x81y\xCC\x81z\x03"s, - "\xA3\x81x\x01\x81y\x02\x85z"s})); +INSTANTIATE_TEST_SUITE_P(Test, MapFailure, + ::testing::ValuesIn({""s, "\xCC"s, "\xD8"s, "\xD9\x01"s, + "\xDA\x01\x02\x03"s, + "\xA3\x81x\x01\x81y\xCC\x81z\x03"s, + "\xA3\x81x\x01\x81y\x02\x85z"s})); class NodeFailure : public DecodingFailure {}; @@ -622,7 +613,7 @@ TEST_P(NodeFailure, Test) { ASSERT_MEMORY_OK(); } -INSTANTIATE_TEST_CASE_P( +INSTANTIATE_TEST_SUITE_P( Test, NodeFailure, ::testing::ValuesIn({""s, "\xB2\x4E"s, "\xB3\x5E"s, "\xB3\x4E"s, "\xB3\x4E\xCC"s, "\xB3\x4E\x01\x95\x82L1\xCC"s, @@ -650,7 +641,7 @@ TEST_P(RelationshipFailure, Test) { ASSERT_MEMORY_OK(); } -INSTANTIATE_TEST_CASE_P( +INSTANTIATE_TEST_SUITE_P( Test, RelationshipFailure, ::testing::ValuesIn({""s, "\xB2\x52"s, "\xB5\x02"s, "\xB5\x52"s, "\xB5\x52\xCC"s, "\xB5\x52\x01\xCC"s, @@ -679,7 +670,7 @@ TEST_P(UnboundRelationshipFailure, Test) { ASSERT_MEMORY_OK(); } -INSTANTIATE_TEST_CASE_P(Test, UnboundRelationshipFailure, +INSTANTIATE_TEST_SUITE_P(Test, UnboundRelationshipFailure, ::testing::ValuesIn({""s, "\xB2\x72"s, "\xB3\x02"s, "\xB3\x72"s, "\xB3\x72\xCC"s, "\xB3\x72\x01\xCC"s, @@ -707,7 +698,7 @@ TEST_P(PathFailure, Test) { ASSERT_MEMORY_OK(); } -INSTANTIATE_TEST_CASE_P( +INSTANTIATE_TEST_SUITE_P( Test, PathFailure, ::testing::ValuesIn( {""s, "\xB2\x50"s, "\xB3\x02"s, "\xB3\x50"s, "\xB3\x50\x92"s, diff --git a/tests/encoder.cpp b/tests/encoder.cpp index 605476d..0fbb9c0 100644 --- a/tests/encoder.cpp +++ b/tests/encoder.cpp @@ -370,37 +370,33 @@ TEST_P(ValueTest, Encoding) { ASSERT_MEMORY_OK(); } -INSTANTIATE_TEST_CASE_P(Null, ValueTest, - ::testing::ValuesIn(NullTestCases())); +INSTANTIATE_TEST_SUITE_P(Null, ValueTest, ::testing::ValuesIn(NullTestCases())); -INSTANTIATE_TEST_CASE_P(Bool, ValueTest, - ::testing::ValuesIn(BoolTestCases())); +INSTANTIATE_TEST_SUITE_P(Bool, ValueTest, ::testing::ValuesIn(BoolTestCases())); -INSTANTIATE_TEST_CASE_P(Integer, ValueTest, +INSTANTIATE_TEST_SUITE_P(Integer, ValueTest, ::testing::ValuesIn(IntegerTestCases())); -INSTANTIATE_TEST_CASE_P(Float, ValueTest, +INSTANTIATE_TEST_SUITE_P(Float, ValueTest, ::testing::ValuesIn(FloatTestCases())); -INSTANTIATE_TEST_CASE_P(String, ValueTest, +INSTANTIATE_TEST_SUITE_P(String, ValueTest, ::testing::ValuesIn(StringTestCases())); -INSTANTIATE_TEST_CASE_P(List, ValueTest, - ::testing::ValuesIn(ListTestCases())); +INSTANTIATE_TEST_SUITE_P(List, ValueTest, ::testing::ValuesIn(ListTestCases())); -INSTANTIATE_TEST_CASE_P(Date, ValueTest, - ::testing::ValuesIn(DateTestCases())); +INSTANTIATE_TEST_SUITE_P(Date, ValueTest, ::testing::ValuesIn(DateTestCases())); -INSTANTIATE_TEST_CASE_P(LocalTime, ValueTest, +INSTANTIATE_TEST_SUITE_P(LocalTime, ValueTest, ::testing::ValuesIn(LocalTimeTestCases())); -INSTANTIATE_TEST_CASE_P(LocalDateTime, ValueTest, +INSTANTIATE_TEST_SUITE_P(LocalDateTime, ValueTest, ::testing::ValuesIn(LocalDateTimeTestCases())); -INSTANTIATE_TEST_CASE_P(DateTime, ValueTest, +INSTANTIATE_TEST_SUITE_P(DateTime, ValueTest, ::testing::ValuesIn(DateTimeTestCases())); -INSTANTIATE_TEST_CASE_P(Duration, ValueTest, +INSTANTIATE_TEST_SUITE_P(Duration, ValueTest, ::testing::ValuesIn((DurationTestCases()))); -INSTANTIATE_TEST_CASE_P(Map, ValueTest, ::testing::ValuesIn(MapTestCases())); +INSTANTIATE_TEST_SUITE_P(Map, ValueTest, ::testing::ValuesIn(MapTestCases())); diff --git a/tests/routing.cpp b/tests/routing.cpp index a3fd376..4bceeb5 100644 --- a/tests/routing.cpp +++ b/tests/routing.cpp @@ -34,8 +34,8 @@ extern "C" int CollapseResolver(const char *advertised, // Resolver that remaps advertised addresses per MEMGRAPH_HA_ADDRESS_MAP // ("adv1=target1,adv2=target2,..."), falling back to identity. Lets the // cluster-gated tests reach a Kubernetes cluster through kubectl port-forwards. -extern "C" int EnvMapResolver(const char *advertised, mg_resolver_result *result, - void *data) { +extern "C" int EnvMapResolver(const char *advertised, + mg_resolver_result *result, void *data) { (void)data; const char *map = std::getenv("MEMGRAPH_HA_ADDRESS_MAP"); if (map) { @@ -82,8 +82,9 @@ static int DrainResults(mg_session *session) { // mg_router_execute_write owns the transaction boundary. extern "C" int WriteNoOpWork(mg_session *session, void *data) { (void)data; - int status = mg_session_run(session, "CREATE (n:_MgRouterExecuteTest) DELETE n", - nullptr, nullptr, nullptr, nullptr); + int status = + mg_session_run(session, "CREATE (n:_MgRouterExecuteTest) DELETE n", + nullptr, nullptr, nullptr, nullptr); if (status != 0) { return status; } @@ -95,8 +96,8 @@ extern "C" int WriteNoOpWork(mg_session *session, void *data) { // A read unit of work: runs "RETURN 1" and stores the value through `data`. extern "C" int ReadReturnsOneWork(mg_session *session, void *data) { - int status = mg_session_run(session, "RETURN 1", nullptr, nullptr, nullptr, - nullptr); + int status = + mg_session_run(session, "RETURN 1", nullptr, nullptr, nullptr, nullptr); if (status != 0) { return status; } @@ -281,10 +282,10 @@ TEST(RouterSelect, ResolverAppliedAndDuplicatesSkipped) { uint32_t read_index = 0; mg_addr_list out; memset(&out, 0, sizeof(out)); - ASSERT_EQ(mg_routing_select_targets(table, MG_ROUTING_ROLE_READ, - CollapseResolver, nullptr, &read_index, - &out), - 0); + ASSERT_EQ( + mg_routing_select_targets(table, MG_ROUTING_ROLE_READ, CollapseResolver, + nullptr, &read_index, &out), + 0); // Both replicas resolve to the same target, so it is listed once. ASSERT_EQ(out.size, 1u); EXPECT_STREQ(out.items[0], "10.0.0.1:7687"); @@ -368,8 +369,9 @@ TEST(RouterRefresh, FailsWhenSeedUnreachable) { int status = mg_router_refresh(router); EXPECT_NE(status, 0); - EXPECT_TRUE(mg_error_is_transient(status)); // connection refused is transient - EXPECT_STRNE(mg_router_error(router), ""); // a message was recorded + EXPECT_TRUE( + mg_error_is_transient(status)); // connection refused is transient + EXPECT_STRNE(mg_router_error(router), ""); // a message was recorded EXPECT_EQ(mg_router_routing_table(router), nullptr); // nothing cached mg_router_destroy(router); @@ -435,7 +437,8 @@ mg_router *MakeRouterWithResolver(const char *host, uint16_t port, return router; } -// Returns the coordinator port from MEMGRAPH_HA_COORDINATOR_PORT (default 7687). +// Returns the coordinator port from MEMGRAPH_HA_COORDINATOR_PORT (default +// 7687). uint16_t CoordinatorPort() { const char *port_str = std::getenv("MEMGRAPH_HA_COORDINATOR_PORT"); return port_str ? static_cast(std::atoi(port_str)) : 7687; @@ -449,8 +452,8 @@ TEST(RouterConnect, WriteReachesMain) { if (!host) { GTEST_SKIP() << "set MEMGRAPH_HA_COORDINATOR_HOST to run"; } - mg_router *router = MakeRouterWithResolver(host, CoordinatorPort(), - EnvMapResolver); + mg_router *router = + MakeRouterWithResolver(host, CoordinatorPort(), EnvMapResolver); ASSERT_NE(router, nullptr); mg_session *session = nullptr; @@ -468,8 +471,8 @@ TEST(RouterConnect, ReadReachesReplica) { if (!host) { GTEST_SKIP() << "set MEMGRAPH_HA_COORDINATOR_HOST to run"; } - mg_router *router = MakeRouterWithResolver(host, CoordinatorPort(), - EnvMapResolver); + mg_router *router = + MakeRouterWithResolver(host, CoordinatorPort(), EnvMapResolver); ASSERT_NE(router, nullptr); mg_session *session = nullptr; @@ -484,7 +487,8 @@ TEST(RouterConnect, ReadReachesReplica) { TEST(ErrorClassification, CommittedOnMainNeedsBothMarkers) { const char *committed = - "Replication Exception: Failed to replicate to SYNC replica 'instance_1': " + "Replication Exception: Failed to replicate to SYNC replica " + "'instance_1': " "replica is not reachable or not in sync with the main. Transaction is " "still committed on the main instance and other alive replicas."; EXPECT_TRUE(mg_error_is_committed_on_main(committed)); @@ -532,7 +536,8 @@ TEST(RoutingTable, ParseIgnoresMalformedEntries) { { mg_map *bad_addrs = mg_map_make_empty(2); mg_map_insert(bad_addrs, "role", mg_value_make_string("WRITE")); - mg_map_insert(bad_addrs, "addresses", mg_value_make_integer(1)); // not a list + mg_map_insert(bad_addrs, "addresses", + mg_value_make_integer(1)); // not a list mg_list_append(servers, mg_value_make_map(bad_addrs)); } // A valid server survives alongside the malformed ones. @@ -564,18 +569,22 @@ TEST(RouterBackoff, CappedExponential) { EXPECT_DOUBLE_EQ(mg_router_backoff_seconds(3, 1.0, 15.0), 4.0); EXPECT_DOUBLE_EQ(mg_router_backoff_seconds(4, 1.0, 15.0), 8.0); EXPECT_DOUBLE_EQ(mg_router_backoff_seconds(5, 1.0, 15.0), 15.0); // capped - EXPECT_DOUBLE_EQ(mg_router_backoff_seconds(9, 1.0, 15.0), 15.0); // stays capped + EXPECT_DOUBLE_EQ(mg_router_backoff_seconds(9, 1.0, 15.0), + 15.0); // stays capped } TEST(RouterBackoff, EdgeCases) { - EXPECT_DOUBLE_EQ(mg_router_backoff_seconds(0, 1.0, 15.0), 0.0); // no attempt 0 + EXPECT_DOUBLE_EQ(mg_router_backoff_seconds(0, 1.0, 15.0), + 0.0); // no attempt 0 EXPECT_DOUBLE_EQ(mg_router_backoff_seconds(1, 0.0, 15.0), 0.0); // zero base - EXPECT_DOUBLE_EQ(mg_router_backoff_seconds(3, 2.5, 5.0), 5.0); // 2.5,5,capped + EXPECT_DOUBLE_EQ(mg_router_backoff_seconds(3, 2.5, 5.0), + 5.0); // 2.5,5,capped } namespace { mg_router *MakeRouterWithRetries(const char *host, uint16_t port, - uint32_t max_retries, double base, double cap) { + uint32_t max_retries, double base, + double cap) { mg_router_config *config = mg_router_config_make(); mg_session_params *params = SeedParams(host, port); mg_router_config_set_session_params(config, params); From eaa577cfc16db38715d2cfc85c98049cd8d56421 Mon Sep 17 00:00:00 2001 From: matt Date: Thu, 9 Jul 2026 14:49:06 +0100 Subject: [PATCH 07/20] don't fail fast for linux --- .github/workflows/ci.yml | 29 +++++++++++++++-------------- 1 file changed, 15 insertions(+), 14 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index acdef15..2f9dadd 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -104,7 +104,8 @@ jobs: build_and_test_linux: if: ${{ github.event_name == 'pull_request' || (github.event_name == 'workflow_dispatch' && inputs.linux) }} strategy: - matrix: + fail-fast: false + matrix: platform: [ubuntu-24.04, fedora-41] mgversion: ["latest"] packages: ["gcc g++ clang cmake git"] @@ -128,7 +129,7 @@ jobs: docker network create ${{ env.MEMGRAPH_NETWORK }} || true platform="${{ matrix.platform }}" tag=${platform//-/:} - docker run -d --rm --name testcontainer --network ${{ env.MEMGRAPH_NETWORK }} "$tag" sleep infinity + docker run -d --rm --name testcontainer --network ${{ env.MEMGRAPH_NETWORK }} "$tag" sleep infinity - name: Install environment run: | @@ -159,7 +160,7 @@ jobs: - name: Load and run Memgraph Docker image run: | docker load -i memgraph-docker.tar.gz - docker run -d --rm --name memgraphcontainer --network ${{ env.MEMGRAPH_NETWORK }} -p 7687:7687 memgraph/memgraph --telemetry-enabled=false + docker run -d --rm --name memgraphcontainer --network ${{ env.MEMGRAPH_NETWORK }} -p 7687:7687 memgraph/memgraph --telemetry-enabled=false rm memgraph-docker.tar.gz sleep 5 docker logs memgraphcontainer @@ -177,13 +178,13 @@ jobs: .." docker exec -i testcontainer bash -c " - mkdir /mgclient/build-gcc && - cd /mgclient/build-gcc && - $cmake_configure && - cmake --build . --parallel && - ctest --output-on-failure && + mkdir /mgclient/build-gcc && + cd /mgclient/build-gcc && + $cmake_configure && + cmake --build . --parallel && + ctest --output-on-failure && make install" - + - name: Build with clang, test and install mgclient run: | cmake_configure="cmake \ @@ -197,11 +198,11 @@ jobs: .." docker exec -i testcontainer bash -c " - mkdir /mgclient/build-clang && - cd /mgclient/build-clang && - $cmake_configure && - cmake --build . --parallel && - ctest --output-on-failure && + mkdir /mgclient/build-clang && + cd /mgclient/build-clang && + $cmake_configure && + cmake --build . --parallel && + ctest --output-on-failure && make install" - name: Cleanup From a0f0b8215a1a537a87a879e293d64f9bb2a420b2 Mon Sep 17 00:00:00 2001 From: matt Date: Thu, 9 Jul 2026 15:04:03 +0100 Subject: [PATCH 08/20] clang format fixes --- tests/decoder.cpp | 60 ++++++++++++++++++++++++----------------------- tests/encoder.cpp | 14 +++++------ 2 files changed, 38 insertions(+), 36 deletions(-) diff --git a/tests/decoder.cpp b/tests/decoder.cpp index 3a0d998..5cd31ef 100644 --- a/tests/decoder.cpp +++ b/tests/decoder.cpp @@ -372,13 +372,13 @@ INSTANTIATE_TEST_SUITE_P(Null, ValueTest, ::testing::ValuesIn(NullTestCases())); INSTANTIATE_TEST_SUITE_P(Bool, ValueTest, ::testing::ValuesIn(BoolTestCases())); INSTANTIATE_TEST_SUITE_P(Integer, ValueTest, - ::testing::ValuesIn(IntegerTestCases())); + ::testing::ValuesIn(IntegerTestCases())); INSTANTIATE_TEST_SUITE_P(Float, ValueTest, - ::testing::ValuesIn(FloatTestCases())); + ::testing::ValuesIn(FloatTestCases())); INSTANTIATE_TEST_SUITE_P(String, ValueTest, - ::testing::ValuesIn(StringTestCases())); + ::testing::ValuesIn(StringTestCases())); INSTANTIATE_TEST_SUITE_P(List, ValueTest, ::testing::ValuesIn(ListTestCases())); @@ -387,10 +387,10 @@ INSTANTIATE_TEST_SUITE_P(Map, ValueTest, ::testing::ValuesIn(MapTestCases())); INSTANTIATE_TEST_SUITE_P(Node, ValueTest, ::testing::ValuesIn(NodeTestCases())); INSTANTIATE_TEST_SUITE_P(Relationship, ValueTest, - ::testing::ValuesIn(RelationshipTestCases())); + ::testing::ValuesIn(RelationshipTestCases())); INSTANTIATE_TEST_SUITE_P(UnboundRelationship, ValueTest, - ::testing::ValuesIn(UnboundRelationshipTestCases())); + ::testing::ValuesIn(UnboundRelationshipTestCases())); INSTANTIATE_TEST_SUITE_P(Path, ValueTest, ::testing::ValuesIn(PathTestCases())); @@ -399,25 +399,25 @@ INSTANTIATE_TEST_SUITE_P(Date, ValueTest, ::testing::ValuesIn(DateTestCases())); INSTANTIATE_TEST_SUITE_P(Time, ValueTest, ::testing::ValuesIn(TimeTestCases())); INSTANTIATE_TEST_SUITE_P(LocalTime, ValueTest, - ::testing::ValuesIn(LocalTimeTestCases())); + ::testing::ValuesIn(LocalTimeTestCases())); INSTANTIATE_TEST_SUITE_P(DateTime, ValueTest, - ::testing::ValuesIn(DateTimeTestCases())); + ::testing::ValuesIn(DateTimeTestCases())); INSTANTIATE_TEST_SUITE_P(DateTimeZoneId, ValueTest, - ::testing::ValuesIn(DateTimeZoneIdTestCases())); + ::testing::ValuesIn(DateTimeZoneIdTestCases())); INSTANTIATE_TEST_SUITE_P(LocalDateTime, ValueTest, - ::testing::ValuesIn(LocalDateTimeTestCases())); + ::testing::ValuesIn(LocalDateTimeTestCases())); INSTANTIATE_TEST_SUITE_P(Duration, ValueTest, - ::testing::ValuesIn(DurationTestCases())); + ::testing::ValuesIn(DurationTestCases())); INSTANTIATE_TEST_SUITE_P(Point2d, ValueTest, - ::testing::ValuesIn(Point2dTestCases())); + ::testing::ValuesIn(Point2dTestCases())); INSTANTIATE_TEST_SUITE_P(Point3d, ValueTest, - ::testing::ValuesIn(Point3dTestCases())); + ::testing::ValuesIn(Point3dTestCases())); // TODO(mtomic): When these tests fail, just a bunch of bytes is outputted, we // might want to make this nicer (maybe add names or descriptions to @@ -480,7 +480,8 @@ TEST_P(BoolFailure, Test) { ASSERT_MEMORY_OK(); } -INSTANTIATE_TEST_SUITE_P(Test, BoolFailure, ::testing::ValuesIn({""s, "\xCC"s})); +INSTANTIATE_TEST_SUITE_P(Test, BoolFailure, + ::testing::ValuesIn({""s, "\xCC"s})); class FloatFailure : public DecodingFailure {}; @@ -531,9 +532,9 @@ TEST_P(StringFailure, Test) { } INSTANTIATE_TEST_SUITE_P(Test, StringFailure, - ::testing::ValuesIn({""s, "\xCC"s, "\xD0"s, "\xD1\x01"s, - "\xD2\x01\x02\x03"s, - "\x85pqrs"s})); + ::testing::ValuesIn({""s, "\xCC"s, "\xD0"s, + "\xD1\x01"s, "\xD2\x01\x02\x03"s, + "\x85pqrs"s})); class ListFailure : public DecodingFailure {}; @@ -558,10 +559,10 @@ TEST_P(ListFailure, Test) { } INSTANTIATE_TEST_SUITE_P(Test, ListFailure, - ::testing::ValuesIn({""s, "\xCC"s, "\xD4"s, "\xD5\x01"s, - "\xD6\x01\x02\x03"s, - "\x93\x01\x02"s, - "\x93\x01\x02\xCC"s})); + ::testing::ValuesIn({""s, "\xCC"s, "\xD4"s, + "\xD5\x01"s, "\xD6\x01\x02\x03"s, + "\x93\x01\x02"s, + "\x93\x01\x02\xCC"s})); class MapFailure : public DecodingFailure {}; @@ -585,11 +586,12 @@ TEST_P(MapFailure, Test) { ASSERT_MEMORY_OK(); } -INSTANTIATE_TEST_SUITE_P(Test, MapFailure, - ::testing::ValuesIn({""s, "\xCC"s, "\xD8"s, "\xD9\x01"s, - "\xDA\x01\x02\x03"s, - "\xA3\x81x\x01\x81y\xCC\x81z\x03"s, - "\xA3\x81x\x01\x81y\x02\x85z"s})); +INSTANTIATE_TEST_SUITE_P( + Test, MapFailure, + ::testing::ValuesIn({""s, "\xCC"s, "\xD8"s, "\xD9\x01"s, + "\xDA\x01\x02\x03"s, + "\xA3\x81x\x01\x81y\xCC\x81z\x03"s, + "\xA3\x81x\x01\x81y\x02\x85z"s})); class NodeFailure : public DecodingFailure {}; @@ -671,10 +673,10 @@ TEST_P(UnboundRelationshipFailure, Test) { } INSTANTIATE_TEST_SUITE_P(Test, UnboundRelationshipFailure, - ::testing::ValuesIn({""s, "\xB2\x72"s, "\xB3\x02"s, - "\xB3\x72"s, "\xB3\x72\xCC"s, - "\xB3\x72\x01\xCC"s, - "\xB3\x72\x01\x84type\xCC"s})); + ::testing::ValuesIn({""s, "\xB2\x72"s, "\xB3\x02"s, + "\xB3\x72"s, "\xB3\x72\xCC"s, + "\xB3\x72\x01\xCC"s, + "\xB3\x72\x01\x84type\xCC"s})); class PathFailure : public DecodingFailure {}; diff --git a/tests/encoder.cpp b/tests/encoder.cpp index 0fbb9c0..24c2237 100644 --- a/tests/encoder.cpp +++ b/tests/encoder.cpp @@ -375,28 +375,28 @@ INSTANTIATE_TEST_SUITE_P(Null, ValueTest, ::testing::ValuesIn(NullTestCases())); INSTANTIATE_TEST_SUITE_P(Bool, ValueTest, ::testing::ValuesIn(BoolTestCases())); INSTANTIATE_TEST_SUITE_P(Integer, ValueTest, - ::testing::ValuesIn(IntegerTestCases())); + ::testing::ValuesIn(IntegerTestCases())); INSTANTIATE_TEST_SUITE_P(Float, ValueTest, - ::testing::ValuesIn(FloatTestCases())); + ::testing::ValuesIn(FloatTestCases())); INSTANTIATE_TEST_SUITE_P(String, ValueTest, - ::testing::ValuesIn(StringTestCases())); + ::testing::ValuesIn(StringTestCases())); INSTANTIATE_TEST_SUITE_P(List, ValueTest, ::testing::ValuesIn(ListTestCases())); INSTANTIATE_TEST_SUITE_P(Date, ValueTest, ::testing::ValuesIn(DateTestCases())); INSTANTIATE_TEST_SUITE_P(LocalTime, ValueTest, - ::testing::ValuesIn(LocalTimeTestCases())); + ::testing::ValuesIn(LocalTimeTestCases())); INSTANTIATE_TEST_SUITE_P(LocalDateTime, ValueTest, - ::testing::ValuesIn(LocalDateTimeTestCases())); + ::testing::ValuesIn(LocalDateTimeTestCases())); INSTANTIATE_TEST_SUITE_P(DateTime, ValueTest, - ::testing::ValuesIn(DateTimeTestCases())); + ::testing::ValuesIn(DateTimeTestCases())); INSTANTIATE_TEST_SUITE_P(Duration, ValueTest, - ::testing::ValuesIn((DurationTestCases()))); + ::testing::ValuesIn((DurationTestCases()))); INSTANTIATE_TEST_SUITE_P(Map, ValueTest, ::testing::ValuesIn(MapTestCases())); From 6285b4628a4cfcc9176fb7baf83980b51b7d9d7a Mon Sep 17 00:00:00 2001 From: matt Date: Thu, 9 Jul 2026 15:29:15 +0100 Subject: [PATCH 09/20] added routing example --- examples/routing.c | 192 +++++++++++++++++++++++++++++++++++++++++++ tests/CMakeLists.txt | 7 ++ 2 files changed, 199 insertions(+) create mode 100644 examples/routing.c diff --git a/examples/routing.c b/examples/routing.c new file mode 100644 index 0000000..011012b --- /dev/null +++ b/examples/routing.c @@ -0,0 +1,192 @@ +// 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. + +// Client-side routing against a Memgraph high-availability (HA) cluster. +// +// You give the router the address of a *coordinator*. It fetches the cluster's +// routing table (which instance is the main, which are replicas), caches it +// until its TTL expires, and hands out connections to the right instance: +// writes go to the main, reads to a replica. +// +// The managed helpers -- mg_router_execute_write / mg_router_execute_read -- +// go one step further: they run your unit of work and, if the cluster is +// briefly in flux (for example while a new main is being elected during a +// failover), they refresh the routing table and retry with exponential +// back-off. So a write issued the instant the old main goes down still lands +// on the new main once it is elected, without any special handling in your +// code. +// +// Usage: routing [username] [password] + +#include +#include + +#include + +// Drain any remaining rows in the current result stream. Returns 0 on success, +// or a negative MG_ERROR_ code if a fetch failed. +static int drain(mg_session *session) { + mg_result *result; + int status; + while ((status = mg_session_fetch(session, &result)) == 1) { + } + return status; +} + +// A managed *write*: create/update a greeting node. +// +// The router owns the transaction around this callback, so the work must NOT +// begin/commit it -- it only issues the query. Because the work may be called +// more than once (on retry), it is written to be idempotent: MERGE rather than +// CREATE, so a retry never duplicates the node. +static int write_greeting(mg_session *session, void *data) { + (void)data; + int status = mg_session_run( + session, "MERGE (n:Greeting {id: 1}) SET n.text = 'hello from routing'", + NULL, NULL, NULL, NULL); + if (status != 0) { + return status; + } + if ((status = mg_session_pull(session, NULL)) != 0) { + return status; + } + return drain(session); +} + +// A managed *read*: read the greeting text back into the caller's buffer. +static int read_greeting(mg_session *session, void *data) { + char *out = (char *)data; + int status = mg_session_run(session, "MATCH (n:Greeting {id: 1}) RETURN n.text", + NULL, NULL, NULL, NULL); + if (status != 0) { + return status; + } + if ((status = mg_session_pull(session, NULL)) != 0) { + return status; + } + + mg_result *result; + while ((status = mg_session_fetch(session, &result)) == 1) { + const mg_list *row = mg_result_row(result); + if (mg_list_size(row) > 0) { + const mg_value *value = mg_list_at(row, 0); + if (mg_value_get_type(value) == MG_VALUE_TYPE_STRING) { + const mg_string *text = mg_value_string(value); + snprintf(out, 256, "%.*s", (int)mg_string_size(text), + mg_string_data(text)); + } + } + } + return status; // 0 once the stream is fully consumed +} + +static void print_routing_table(mg_router *router) { + if (mg_router_refresh(router) != 0) { + printf("could not fetch routing table: %s\n", mg_router_error(router)); + return; + } + const mg_routing_table *table = mg_router_routing_table(router); + printf("routing table (ttl %lld s):\n", + (long long)mg_routing_table_ttl(table)); + + const struct { + enum mg_routing_role role; + const char *name; + } roles[] = {{MG_ROUTING_ROLE_WRITE, "WRITE"}, + {MG_ROUTING_ROLE_READ, "READ"}, + {MG_ROUTING_ROLE_ROUTE, "ROUTE"}}; + for (size_t r = 0; r < sizeof(roles) / sizeof(roles[0]); ++r) { + uint32_t count = mg_routing_table_address_count(table, roles[r].role); + for (uint32_t i = 0; i < count; ++i) { + printf(" %-5s %s\n", roles[r].name, + mg_routing_table_address_at(table, roles[r].role, i)); + } + } +} + +int main(int argc, char *argv[]) { + if (argc < 3 || argc > 5) { + fprintf(stderr, + "Usage: %s " + "[username] [password]\n", + argv[0]); + return 1; + } + + mg_init(); + printf("mgclient version: %s\n", mg_client_version()); + + // The seed coordinator, plus the connection template (auth, SSL, ...) that + // the router reuses for every routed connection it opens. + mg_session_params *params = mg_session_params_make(); + mg_session_params_set_host(params, argv[1]); + mg_session_params_set_port(params, (uint16_t)atoi(argv[2])); + mg_session_params_set_sslmode(params, MG_SSLMODE_DISABLE); + if (argc >= 4) { + mg_session_params_set_username(params, argv[3]); + } + if (argc >= 5) { + mg_session_params_set_password(params, argv[4]); + } + + mg_router_config *config = mg_router_config_make(); + mg_router_config_set_session_params(config, params); + // The managed-transaction retry budget. These are the defaults, shown here + // only to make them explicit: up to 8 attempts, backing off 1s, 2s, 4s, ... + // capped at 15s between them. + mg_router_config_set_max_retries(config, 8); + mg_router_config_set_retry_backoff(config, 1.0, 15.0); + // If the cluster advertises addresses the client can't reach directly, set an + // address resolver here with mg_router_config_set_resolver(). Omitted for + // simplicity: advertised addresses are used as-is. + + mg_router *router = mg_router_make(config); + mg_session_params_destroy(params); + mg_router_config_destroy(config); + if (!router) { + fprintf(stderr, "failed to create router\n"); + mg_finalize(); + return 1; + } + + int exit_code = 0; + + // Show the topology the coordinator reports. + print_routing_table(router); + + // Managed write -> routed to the main, wrapped in a transaction, and retried + // (with a routing refresh) across a failover. + if (mg_router_execute_write(router, write_greeting, NULL) != 0) { + printf("write failed: %s\n", mg_router_error(router)); + exit_code = 1; + goto cleanup; + } + printf("wrote greeting to the main\n"); + + // Managed read -> routed to a replica (round-robined across replicas on + // successive reads). Note: under ASYNC replication a replica may momentarily + // lag the main, so a freshly written value might not be visible yet. + char greeting[256] = ""; + if (mg_router_execute_read(router, read_greeting, greeting) != 0) { + printf("read failed: %s\n", mg_router_error(router)); + exit_code = 1; + goto cleanup; + } + printf("read greeting from a replica: \"%s\"\n", greeting); + +cleanup: + mg_router_destroy(router); + mg_finalize(); + return exit_code; +} diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index ded158e..cf079c0 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -76,3 +76,10 @@ add_test(example_basic_cpp example_basic_cpp 127.0.0.1 7687 "RETURN 1") add_executable(example_advanced_cpp ${EXAMPLE_DIR}/advanced.cpp) target_link_libraries(example_advanced_cpp mgclient-static mgclient_cpp project_cpp_warnings) add_test(example_advanced_cpp example_advanced_cpp 127.0.0.1 7687) + +# Client-side routing example. Built (so it stays compilable) but not run as a +# test: it needs a high-availability cluster coordinator, which CI does not +# provide. Run it manually against a coordinator, e.g. +# ./example_routing_c [user] [password] +add_executable(example_routing_c ${EXAMPLE_DIR}/routing.c) +target_link_libraries(example_routing_c mgclient-static project_c_warnings) From cabbcd98e9f3614663d0e6eaedb546da89399594 Mon Sep 17 00:00:00 2001 From: matt Date: Thu, 9 Jul 2026 15:52:53 +0100 Subject: [PATCH 10/20] added HA cluster to the linux workflow --- .github/workflows/ci.yml | 107 ++++++++++++++++++++++++++++++++++++++- 1 file changed, 105 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2f9dadd..52fc9c6 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -165,8 +165,101 @@ jobs: sleep 5 docker logs memgraphcontainer + - name: Run Memgraph HA Cluster + env: + MEMGRAPH_ENTERPRISE_LICENSE: ${{ secrets.MEMGRAPH_ENTERPRISE_LICENSE }} + MEMGRAPH_ORGANIZATION_NAME: ${{ secrets.MEMGRAPH_ORGANIZATION_NAME }} + run: | + # High availability is a Memgraph Enterprise feature. The license + # secrets are unavailable on PRs from forks, so skip the cluster (and + # the routing tests, which GTEST_SKIP when the coordinator env vars are + # unset) 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 routing tests." + exit 0 + fi + + net="${{ env.MEMGRAPH_NETWORK }}" + lic=(-e "MEMGRAPH_ENTERPRISE_LICENSE=$MEMGRAPH_ENTERPRISE_LICENSE" \ + -e "MEMGRAPH_ORGANIZATION_NAME=$MEMGRAPH_ORGANIZATION_NAME") + + # Three data instances. On host networking every container shares + # localhost, so each needs a distinct set of ports (the single + # instance above already holds 7687). + docker run -d --rm --name mg-data1 --network "$net" "${lic[@]}" memgraph/memgraph \ + --bolt-port=7688 --management-port=13011 --telemetry-enabled=false + docker run -d --rm --name mg-data2 --network "$net" "${lic[@]}" memgraph/memgraph \ + --bolt-port=7689 --management-port=13012 --telemetry-enabled=false + docker run -d --rm --name mg-data3 --network "$net" "${lic[@]}" memgraph/memgraph \ + --bolt-port=7690 --management-port=13013 --telemetry-enabled=false + + # Three coordinators for a Raft quorum. mg-coord1 is the bootstrap + # leader; the others are added during registration below. + docker run -d --rm --name mg-coord1 --network "$net" "${lic[@]}" memgraph/memgraph \ + --bolt-port=7691 --coordinator-id=1 --coordinator-port=12121 \ + --coordinator-hostname=127.0.0.1 --management-port=13021 --telemetry-enabled=false + docker run -d --rm --name mg-coord2 --network "$net" "${lic[@]}" memgraph/memgraph \ + --bolt-port=7692 --coordinator-id=2 --coordinator-port=12122 \ + --coordinator-hostname=127.0.0.1 --management-port=13022 --telemetry-enabled=false + docker run -d --rm --name mg-coord3 --network "$net" "${lic[@]}" memgraph/memgraph \ + --bolt-port=7693 --coordinator-id=3 --coordinator-port=12123 \ + --coordinator-hostname=127.0.0.1 --management-port=13023 --telemetry-enabled=false + + # Fail early (with logs) if any container didn't come up, rather than + # as an opaque connection error later. + sleep 10 + for name in mg-data1 mg-data2 mg-data3 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 + + # Register the topology on the bootstrap coordinator (mg-coord1), + # using the mgconsole binary bundled in the memgraph image. Addresses + # are 127.0.0.1: because every container is on host networking + # and therefore reachable there. + printf '%s\n' \ + 'ADD COORDINATOR 2 WITH CONFIG {"bolt_server": "127.0.0.1:7692", "coordinator_server": "127.0.0.1:12122", "management_server": "127.0.0.1:13022"};' \ + 'ADD COORDINATOR 3 WITH CONFIG {"bolt_server": "127.0.0.1:7693", "coordinator_server": "127.0.0.1:12123", "management_server": "127.0.0.1:13023"};' \ + 'REGISTER INSTANCE instance_1 WITH CONFIG {"bolt_server": "127.0.0.1:7688", "management_server": "127.0.0.1:13011", "replication_server": "127.0.0.1:10001"};' \ + 'REGISTER INSTANCE instance_2 WITH CONFIG {"bolt_server": "127.0.0.1:7689", "management_server": "127.0.0.1:13012", "replication_server": "127.0.0.1:10002"};' \ + 'REGISTER INSTANCE instance_3 WITH CONFIG {"bolt_server": "127.0.0.1:7690", "management_server": "127.0.0.1:13013", "replication_server": "127.0.0.1:10003"};' \ + 'SET INSTANCE instance_1 TO MAIN;' \ + | docker exec -i mg-coord1 mgconsole --host 127.0.0.1 --port 7691 + + # Wait for the cluster to converge before letting the tests run: the + # coordinator must advertise a main (WRITE) and at least one replica. + converged=false + for _ in $(seq 1 60); do + instances="$(echo 'SHOW INSTANCES;' \ + | docker exec -i mg-coord1 mgconsole --host 127.0.0.1 --port 7691 2>/dev/null || true)" + if echo "$instances" | grep -qiw main && echo "$instances" | grep -qiw replica; then + converged=true + break + fi + sleep 1 + done + if [ "$converged" != "true" ]; then + echo "::error::HA cluster did not converge" + echo "$instances" + exit 1 + fi + + # Tell the test steps to run the routing tests against mg-coord1. + echo "MEMGRAPH_HA_COORDINATOR_HOST=127.0.0.1" >> "$GITHUB_ENV" + echo "MEMGRAPH_HA_COORDINATOR_PORT=7691" >> "$GITHUB_ENV" + - name: Build with gcc, test and install mgclient run: | + # Forward the HA coordinator env vars into the container only when the + # cluster is up; when unset the routing tests GTEST_SKIP. + ha_env=() + if [ -n "${MEMGRAPH_HA_COORDINATOR_HOST:-}" ]; then + ha_env=(-e "MEMGRAPH_HA_COORDINATOR_HOST=${MEMGRAPH_HA_COORDINATOR_HOST}" \ + -e "MEMGRAPH_HA_COORDINATOR_PORT=${MEMGRAPH_HA_COORDINATOR_PORT}") + fi cmake_configure="cmake \ -DCMAKE_C_COMPILER=gcc${{ matrix.gcc-postfix }} \ -DCMAKE_CXX_COMPILER=g++${{ matrix.gcc-postfix }} \ @@ -177,7 +270,7 @@ jobs: -DCPP_WARNINGS_AS_ERRORS=ON \ .." - docker exec -i testcontainer bash -c " + docker exec -i "${ha_env[@]}" testcontainer bash -c " mkdir /mgclient/build-gcc && cd /mgclient/build-gcc && $cmake_configure && @@ -187,6 +280,13 @@ jobs: - name: Build with clang, test and install mgclient run: | + # Forward the HA coordinator env vars into the container only when the + # cluster is up; when unset the routing tests GTEST_SKIP. + ha_env=() + if [ -n "${MEMGRAPH_HA_COORDINATOR_HOST:-}" ]; then + ha_env=(-e "MEMGRAPH_HA_COORDINATOR_HOST=${MEMGRAPH_HA_COORDINATOR_HOST}" \ + -e "MEMGRAPH_HA_COORDINATOR_PORT=${MEMGRAPH_HA_COORDINATOR_PORT}") + fi cmake_configure="cmake \ -DCMAKE_C_COMPILER=clang${{ matrix.clang-postfix }} \ -DCMAKE_CXX_COMPILER=clang++${{ matrix.clang-postfix }} \ @@ -197,7 +297,7 @@ jobs: -DCPP_WARNINGS_AS_ERRORS=ON \ .." - docker exec -i testcontainer bash -c " + docker exec -i "${ha_env[@]}" testcontainer bash -c " mkdir /mgclient/build-clang && cd /mgclient/build-clang && $cmake_configure && @@ -210,6 +310,9 @@ jobs: run: | docker stop testcontainer || echo "testcontainer already stopped" docker stop memgraphcontainer || echo "memgraphcontainer already stopped" + for name in mg-data1 mg-data2 mg-data3 mg-coord1 mg-coord2 mg-coord3; do + docker stop "$name" || echo "$name already stopped" + done docker wait testcontainer || true docker wait memgraphcontainer || true docker rm "${{ env.MEMGRAPH_NETWORK }}" || echo "network already removed" From 6079fac926cbc68aeb7d82288f359d2a039a9ade Mon Sep 17 00:00:00 2001 From: matt Date: Thu, 9 Jul 2026 15:58:46 +0100 Subject: [PATCH 11/20] added routingh example step to CI --- .github/workflows/ci.yml | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 52fc9c6..8a5c07a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -305,6 +305,12 @@ jobs: ctest --output-on-failure && make install" + - name: Run routing example + run: | + docker exec -i testcontainer \ + /mgclient/build-gcc/tests/example_routing_c \ + "$MEMGRAPH_HA_COORDINATOR_HOST" "$MEMGRAPH_HA_COORDINATOR_PORT" + - name: Cleanup if: always() run: | From 263faccef8d19f27b6e7716776d3fe4f84cdf1a6 Mon Sep 17 00:00:00 2001 From: matt Date: Thu, 9 Jul 2026 16:05:32 +0100 Subject: [PATCH 12/20] clang format --- examples/routing.c | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/examples/routing.c b/examples/routing.c index 011012b..720a8c0 100644 --- a/examples/routing.c +++ b/examples/routing.c @@ -67,8 +67,9 @@ static int write_greeting(mg_session *session, void *data) { // A managed *read*: read the greeting text back into the caller's buffer. static int read_greeting(mg_session *session, void *data) { char *out = (char *)data; - int status = mg_session_run(session, "MATCH (n:Greeting {id: 1}) RETURN n.text", - NULL, NULL, NULL, NULL); + int status = + mg_session_run(session, "MATCH (n:Greeting {id: 1}) RETURN n.text", NULL, + NULL, NULL, NULL); if (status != 0) { return status; } From a5297c59df31467a695f7511f6b17fcdd107c4c8 Mon Sep 17 00:00:00 2001 From: matt Date: Thu, 9 Jul 2026 17:48:24 +0100 Subject: [PATCH 13/20] remove mg_error_is_committed_on_main --- include/mgclient.h | 13 +------------ src/mgrouting.c | 37 +------------------------------------ tests/routing.cpp | 20 -------------------- 3 files changed, 2 insertions(+), 68 deletions(-) diff --git a/include/mgclient.h b/include/mgclient.h index 6ad035c..368921b 100644 --- a/include/mgclient.h +++ b/include/mgclient.h @@ -1485,15 +1485,6 @@ MGCLIENT_EXPORT const char *mg_routing_table_address_at( /// errors, SSL errors, and the client/database error categories) return 0. MGCLIENT_EXPORT int mg_error_is_transient(int error); -/// Returns non-zero if \p message reports a write that committed on the main -/// but could not be replicated to a synchronous replica. -/// -/// Such a write is durable, so it is safe to treat as success rather than -/// retry it (a retry would duplicate it). This is the one condition that has no -/// distinct error code and must be recognised from the message text (e.g. as -/// returned by \ref mg_session_error). \p message may be NULL (returns 0). -MGCLIENT_EXPORT int mg_error_is_committed_on_main(const char *message); - /// An opaque accumulator to which a \ref mg_resolver_fn appends the candidate /// "host:port" targets that an advertised address resolves to. typedef struct mg_resolver_result mg_resolver_result; @@ -1672,9 +1663,7 @@ MGCLIENT_EXPORT int mg_router_execute_read(mg_router *router, mg_work_fn work, /// /// Like \ref mg_router_execute_read, but routed to the main and wrapped in an /// explicit transaction that execute begins before \p work and commits after -/// it. A write that committed on the main but could not reach a SYNC replica is -/// durable, so it is treated as success and NOT retried (a retry would -/// duplicate the write); see \ref mg_error_is_committed_on_main. +/// it. MGCLIENT_EXPORT int mg_router_execute_write(mg_router *router, mg_work_fn work, void *work_data); diff --git a/src/mgrouting.c b/src/mgrouting.c index e0edefd..45bd2bc 100644 --- a/src/mgrouting.c +++ b/src/mgrouting.c @@ -15,7 +15,6 @@ #include "mgrouting.h" #include "mgclient.h" -#include #include #include #include @@ -191,34 +190,6 @@ int mg_error_is_transient(int error) { } } -// Case-insensitive substring search. `needle` is matched regardless of case; -// strcasestr is a non-standard extension so we roll our own for portability. -static int contains_ci(const char *haystack, const char *needle) { - if (!haystack || !needle) { - return 0; - } - size_t needle_len = strlen(needle); - if (needle_len == 0) { - return 1; - } - for (const char *h = haystack; *h; ++h) { - size_t i = 0; - while (i < needle_len && h[i] && - tolower((unsigned char)h[i]) == tolower((unsigned char)needle[i])) { - ++i; - } - if (i == needle_len) { - return 1; - } - } - return 0; -} - -int mg_error_is_committed_on_main(const char *message) { - return contains_ci(message, "replication exception") && - contains_ci(message, "committed on the main"); -} - // --------------------------------------------------------------------------- // Router configuration and lifecycle. // --------------------------------------------------------------------------- @@ -821,13 +792,7 @@ static int router_run_unit(mg_router *router, mg_session *session, int writing, mg_result *result = NULL; status = mg_session_commit_transaction(session, &result); if (status != 0) { - const char *message = mg_session_error(session); - if (mg_error_is_committed_on_main(message)) { - // The write is durable on the main; a retry would duplicate it, so - // report success even though the SYNC-replica guarantee was not met. - return 0; - } - router_set_error(router, message); + router_set_error(router, mg_session_error(session)); return status; } } diff --git a/tests/routing.cpp b/tests/routing.cpp index 4bceeb5..56e1291 100644 --- a/tests/routing.cpp +++ b/tests/routing.cpp @@ -485,26 +485,6 @@ TEST(RouterConnect, ReadReachesReplica) { mg_router_destroy(router); } -TEST(ErrorClassification, CommittedOnMainNeedsBothMarkers) { - const char *committed = - "Replication Exception: Failed to replicate to SYNC replica " - "'instance_1': " - "replica is not reachable or not in sync with the main. Transaction is " - "still committed on the main instance and other alive replicas."; - EXPECT_TRUE(mg_error_is_committed_on_main(committed)); - - // Case-insensitive. - EXPECT_TRUE(mg_error_is_committed_on_main( - "REPLICATION EXCEPTION ... COMMITTED ON THE MAIN instance")); - - // A replication error where the transaction was aborted is NOT committed. - EXPECT_FALSE(mg_error_is_committed_on_main( - "Replication Exception: ... Transaction was aborted on all instances.")); - // Unrelated errors and NULL. - EXPECT_FALSE(mg_error_is_committed_on_main("Syntax error near 'FOO'")); - EXPECT_FALSE(mg_error_is_committed_on_main(nullptr)); -} - TEST(RoutingTable, AddressAtOutOfRangeReturnsNull) { mg_list *servers = mg_list_make_empty(1); mg_list_append(servers, Server({"m:7687"}, "WRITE")); From 56886d3ef96083dd4b1f73a58297ee50c1a50b8e Mon Sep 17 00:00:00 2001 From: matt Date: Fri, 10 Jul 2026 09:22:19 +0100 Subject: [PATCH 14/20] remove kubernetes-specific logic for local testing --- tests/routing.cpp | 57 ++++++----------------------------------------- 1 file changed, 7 insertions(+), 50 deletions(-) diff --git a/tests/routing.cpp b/tests/routing.cpp index 56e1291..88a844b 100644 --- a/tests/routing.cpp +++ b/tests/routing.cpp @@ -31,34 +31,6 @@ extern "C" int CollapseResolver(const char *advertised, return mg_resolver_result_add(result, "10.0.0.1:7687"); } -// Resolver that remaps advertised addresses per MEMGRAPH_HA_ADDRESS_MAP -// ("adv1=target1,adv2=target2,..."), falling back to identity. Lets the -// cluster-gated tests reach a Kubernetes cluster through kubectl port-forwards. -extern "C" int EnvMapResolver(const char *advertised, - mg_resolver_result *result, void *data) { - (void)data; - const char *map = std::getenv("MEMGRAPH_HA_ADDRESS_MAP"); - if (map) { - const std::string advertised_str(advertised); - const std::string entries(map); - size_t pos = 0; - while (pos <= entries.size()) { - size_t comma = entries.find(',', pos); - std::string pair = entries.substr( - pos, comma == std::string::npos ? std::string::npos : comma - pos); - size_t eq = pair.find('='); - if (eq != std::string::npos && pair.substr(0, eq) == advertised_str) { - return mg_resolver_result_add(result, pair.substr(eq + 1).c_str()); - } - if (comma == std::string::npos) { - break; - } - pos = comma + 1; - } - } - return mg_resolver_result_add(result, advertised); -} - // A unit of work (C linkage) that just counts how many times it is invoked, so // tests can assert whether the router ever reached the work stage. extern "C" int CountingWork(mg_session *session, void *data) { @@ -425,18 +397,6 @@ std::string ReplicationRole(mg_session *session) { return role; } -mg_router *MakeRouterWithResolver(const char *host, uint16_t port, - mg_resolver_fn resolver) { - mg_router_config *config = mg_router_config_make(); - mg_session_params *params = SeedParams(host, port); - mg_router_config_set_session_params(config, params); - mg_router_config_set_resolver(config, resolver, nullptr); - mg_router *router = mg_router_make(config); - mg_session_params_destroy(params); - mg_router_config_destroy(config); - return router; -} - // Returns the coordinator port from MEMGRAPH_HA_COORDINATOR_PORT (default // 7687). uint16_t CoordinatorPort() { @@ -445,15 +405,15 @@ uint16_t CoordinatorPort() { } } // namespace -// Cluster-gated: set MEMGRAPH_HA_COORDINATOR_HOST (and MEMGRAPH_HA_ADDRESS_MAP -// if the advertised addresses are not directly reachable) to run these. +// Cluster-gated: set MEMGRAPH_HA_COORDINATOR_HOST to run these. The cluster's +// advertised instance addresses must be directly reachable from here (e.g. a +// local Docker HA cluster on the host network, as CI runs). TEST(RouterConnect, WriteReachesMain) { const char *host = std::getenv("MEMGRAPH_HA_COORDINATOR_HOST"); if (!host) { GTEST_SKIP() << "set MEMGRAPH_HA_COORDINATOR_HOST to run"; } - mg_router *router = - MakeRouterWithResolver(host, CoordinatorPort(), EnvMapResolver); + mg_router *router = MakeRouter(host, CoordinatorPort()); ASSERT_NE(router, nullptr); mg_session *session = nullptr; @@ -471,8 +431,7 @@ TEST(RouterConnect, ReadReachesReplica) { if (!host) { GTEST_SKIP() << "set MEMGRAPH_HA_COORDINATOR_HOST to run"; } - mg_router *router = - MakeRouterWithResolver(host, CoordinatorPort(), EnvMapResolver); + mg_router *router = MakeRouter(host, CoordinatorPort()); ASSERT_NE(router, nullptr); mg_session *session = nullptr; @@ -610,8 +569,7 @@ TEST(RouterExecute, WriteCommits) { if (!host) { GTEST_SKIP() << "set MEMGRAPH_HA_COORDINATOR_HOST to run"; } - mg_router *router = - MakeRouterWithResolver(host, CoordinatorPort(), EnvMapResolver); + mg_router *router = MakeRouter(host, CoordinatorPort()); ASSERT_NE(router, nullptr); int status = mg_router_execute_write(router, WriteNoOpWork, nullptr); @@ -625,8 +583,7 @@ TEST(RouterExecute, ReadRuns) { if (!host) { GTEST_SKIP() << "set MEMGRAPH_HA_COORDINATOR_HOST to run"; } - mg_router *router = - MakeRouterWithResolver(host, CoordinatorPort(), EnvMapResolver); + mg_router *router = MakeRouter(host, CoordinatorPort()); ASSERT_NE(router, nullptr); int value = 0; From 1cbec3eabce22122c2687b7386395a5d34d45915 Mon Sep 17 00:00:00 2001 From: matt Date: Fri, 10 Jul 2026 11:18:07 +0100 Subject: [PATCH 15/20] create HA cluster script --- .github/workflows/ci.yml | 72 +--------------------- tool/ha_cluster.sh | 128 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 130 insertions(+), 70 deletions(-) create mode 100755 tool/ha_cluster.sh diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8a5c07a..33bfde3 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -179,73 +179,7 @@ jobs: exit 0 fi - net="${{ env.MEMGRAPH_NETWORK }}" - lic=(-e "MEMGRAPH_ENTERPRISE_LICENSE=$MEMGRAPH_ENTERPRISE_LICENSE" \ - -e "MEMGRAPH_ORGANIZATION_NAME=$MEMGRAPH_ORGANIZATION_NAME") - - # Three data instances. On host networking every container shares - # localhost, so each needs a distinct set of ports (the single - # instance above already holds 7687). - docker run -d --rm --name mg-data1 --network "$net" "${lic[@]}" memgraph/memgraph \ - --bolt-port=7688 --management-port=13011 --telemetry-enabled=false - docker run -d --rm --name mg-data2 --network "$net" "${lic[@]}" memgraph/memgraph \ - --bolt-port=7689 --management-port=13012 --telemetry-enabled=false - docker run -d --rm --name mg-data3 --network "$net" "${lic[@]}" memgraph/memgraph \ - --bolt-port=7690 --management-port=13013 --telemetry-enabled=false - - # Three coordinators for a Raft quorum. mg-coord1 is the bootstrap - # leader; the others are added during registration below. - docker run -d --rm --name mg-coord1 --network "$net" "${lic[@]}" memgraph/memgraph \ - --bolt-port=7691 --coordinator-id=1 --coordinator-port=12121 \ - --coordinator-hostname=127.0.0.1 --management-port=13021 --telemetry-enabled=false - docker run -d --rm --name mg-coord2 --network "$net" "${lic[@]}" memgraph/memgraph \ - --bolt-port=7692 --coordinator-id=2 --coordinator-port=12122 \ - --coordinator-hostname=127.0.0.1 --management-port=13022 --telemetry-enabled=false - docker run -d --rm --name mg-coord3 --network "$net" "${lic[@]}" memgraph/memgraph \ - --bolt-port=7693 --coordinator-id=3 --coordinator-port=12123 \ - --coordinator-hostname=127.0.0.1 --management-port=13023 --telemetry-enabled=false - - # Fail early (with logs) if any container didn't come up, rather than - # as an opaque connection error later. - sleep 10 - for name in mg-data1 mg-data2 mg-data3 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 - - # Register the topology on the bootstrap coordinator (mg-coord1), - # using the mgconsole binary bundled in the memgraph image. Addresses - # are 127.0.0.1: because every container is on host networking - # and therefore reachable there. - printf '%s\n' \ - 'ADD COORDINATOR 2 WITH CONFIG {"bolt_server": "127.0.0.1:7692", "coordinator_server": "127.0.0.1:12122", "management_server": "127.0.0.1:13022"};' \ - 'ADD COORDINATOR 3 WITH CONFIG {"bolt_server": "127.0.0.1:7693", "coordinator_server": "127.0.0.1:12123", "management_server": "127.0.0.1:13023"};' \ - 'REGISTER INSTANCE instance_1 WITH CONFIG {"bolt_server": "127.0.0.1:7688", "management_server": "127.0.0.1:13011", "replication_server": "127.0.0.1:10001"};' \ - 'REGISTER INSTANCE instance_2 WITH CONFIG {"bolt_server": "127.0.0.1:7689", "management_server": "127.0.0.1:13012", "replication_server": "127.0.0.1:10002"};' \ - 'REGISTER INSTANCE instance_3 WITH CONFIG {"bolt_server": "127.0.0.1:7690", "management_server": "127.0.0.1:13013", "replication_server": "127.0.0.1:10003"};' \ - 'SET INSTANCE instance_1 TO MAIN;' \ - | docker exec -i mg-coord1 mgconsole --host 127.0.0.1 --port 7691 - - # Wait for the cluster to converge before letting the tests run: the - # coordinator must advertise a main (WRITE) and at least one replica. - converged=false - for _ in $(seq 1 60); do - instances="$(echo 'SHOW INSTANCES;' \ - | docker exec -i mg-coord1 mgconsole --host 127.0.0.1 --port 7691 2>/dev/null || true)" - if echo "$instances" | grep -qiw main && echo "$instances" | grep -qiw replica; then - converged=true - break - fi - sleep 1 - done - if [ "$converged" != "true" ]; then - echo "::error::HA cluster did not converge" - echo "$instances" - exit 1 - fi + MEMGRAPH_HA_NETWORK="${{ env.MEMGRAPH_NETWORK }}" ./tool/ha_cluster.sh start # Tell the test steps to run the routing tests against mg-coord1. echo "MEMGRAPH_HA_COORDINATOR_HOST=127.0.0.1" >> "$GITHUB_ENV" @@ -316,9 +250,7 @@ jobs: run: | docker stop testcontainer || echo "testcontainer already stopped" docker stop memgraphcontainer || echo "memgraphcontainer already stopped" - for name in mg-data1 mg-data2 mg-data3 mg-coord1 mg-coord2 mg-coord3; do - docker stop "$name" || echo "$name already stopped" - done + ./tool/ha_cluster.sh stop || echo "HA cluster already stopped" docker wait testcontainer || true docker wait memgraphcontainer || true docker rm "${{ env.MEMGRAPH_NETWORK }}" || echo "network already removed" diff --git a/tool/ha_cluster.sh b/tool/ha_cluster.sh new file mode 100755 index 0000000..12f387f --- /dev/null +++ b/tool/ha_cluster.sh @@ -0,0 +1,128 @@ +#!/bin/bash + +# Start (or stop) a local Memgraph high-availability cluster in Docker: three +# data instances and three coordinators. It is used both by CI and for running +# the client-side routing tests / examples locally. +# +# High availability is a Memgraph Enterprise feature, so `start` requires: +# MEMGRAPH_ENTERPRISE_LICENSE, MEMGRAPH_ORGANIZATION_NAME +# +# Every container runs on the Docker "host" network (override with +# MEMGRAPH_HA_NETWORK), so each instance uses a distinct port on localhost and +# the addresses the coordinator advertises (127.0.0.1:) are directly +# reachable -- no address resolver needed. +# +# Usage: +# tool/ha_cluster.sh start # start, register the topology, wait to converge +# tool/ha_cluster.sh stop # stop and remove the containers +# +# On success `start` prints the coordinator to point clients at, e.g.: +# MEMGRAPH_HA_COORDINATOR_HOST=127.0.0.1 MEMGRAPH_HA_COORDINATOR_PORT=7691 + +set -Eeuo pipefail + +NETWORK="${MEMGRAPH_HA_NETWORK:-host}" +IMAGE="${MEMGRAPH_HA_IMAGE:-memgraph/memgraph}" +COORDINATOR_HOST=127.0.0.1 +COORDINATOR_PORT=7691 + +CONTAINERS=(mg-data1 mg-data2 mg-data3 mg-coord1 mg-coord2 mg-coord3) + +# Run mgconsole (bundled in the memgraph image) against the bootstrap +# coordinator, reading queries from stdin. +mgconsole() { + docker exec -i mg-coord1 mgconsole --host "$COORDINATOR_HOST" \ + --port "$COORDINATOR_PORT" +} + +stop_cluster() { + for name in "${CONTAINERS[@]}"; do + docker stop "$name" >/dev/null 2>&1 || true + done +} + +start_cluster() { + if [ -z "${MEMGRAPH_ENTERPRISE_LICENSE:-}" ] || + [ -z "${MEMGRAPH_ORGANIZATION_NAME:-}" ]; then + echo "error: set MEMGRAPH_ENTERPRISE_LICENSE and MEMGRAPH_ORGANIZATION_NAME" \ + "(high availability is a Memgraph Enterprise feature)" >&2 + exit 1 + fi + + local lic=(-e "MEMGRAPH_ENTERPRISE_LICENSE=$MEMGRAPH_ENTERPRISE_LICENSE" \ + -e "MEMGRAPH_ORGANIZATION_NAME=$MEMGRAPH_ORGANIZATION_NAME") + + # Three data instances. On host networking every container shares localhost, + # so each needs a distinct set of ports. + docker run -d --rm --name mg-data1 --network "$NETWORK" "${lic[@]}" "$IMAGE" \ + --bolt-port=7688 --management-port=13011 --telemetry-enabled=false + docker run -d --rm --name mg-data2 --network "$NETWORK" "${lic[@]}" "$IMAGE" \ + --bolt-port=7689 --management-port=13012 --telemetry-enabled=false + docker run -d --rm --name mg-data3 --network "$NETWORK" "${lic[@]}" "$IMAGE" \ + --bolt-port=7690 --management-port=13013 --telemetry-enabled=false + + # Three coordinators for a Raft quorum. mg-coord1 is the bootstrap leader; + # the others are added during registration below. + docker run -d --rm --name mg-coord1 --network "$NETWORK" "${lic[@]}" "$IMAGE" \ + --bolt-port=7691 --coordinator-id=1 --coordinator-port=12121 \ + --coordinator-hostname=127.0.0.1 --management-port=13021 --telemetry-enabled=false + docker run -d --rm --name mg-coord2 --network "$NETWORK" "${lic[@]}" "$IMAGE" \ + --bolt-port=7692 --coordinator-id=2 --coordinator-port=12122 \ + --coordinator-hostname=127.0.0.1 --management-port=13022 --telemetry-enabled=false + docker run -d --rm --name mg-coord3 --network "$NETWORK" "${lic[@]}" "$IMAGE" \ + --bolt-port=7693 --coordinator-id=3 --coordinator-port=12123 \ + --coordinator-hostname=127.0.0.1 --management-port=13023 --telemetry-enabled=false + + # Fail early (with logs) if any container didn't come up, rather than as an + # opaque connection error later. + sleep 10 + for name in "${CONTAINERS[@]}"; do + if [ "$(docker inspect -f '{{.State.Running}}' "$name" 2>/dev/null)" != "true" ]; then + echo "error: Memgraph HA container $name is not running" >&2 + docker logs "$name" || true + exit 1 + fi + done + + # Register the topology on the bootstrap coordinator. Addresses are + # 127.0.0.1: because every container is on host networking. + printf '%s\n' \ + 'ADD COORDINATOR 2 WITH CONFIG {"bolt_server": "127.0.0.1:7692", "coordinator_server": "127.0.0.1:12122", "management_server": "127.0.0.1:13022"};' \ + 'ADD COORDINATOR 3 WITH CONFIG {"bolt_server": "127.0.0.1:7693", "coordinator_server": "127.0.0.1:12123", "management_server": "127.0.0.1:13023"};' \ + 'REGISTER INSTANCE instance_1 WITH CONFIG {"bolt_server": "127.0.0.1:7688", "management_server": "127.0.0.1:13011", "replication_server": "127.0.0.1:10001"};' \ + 'REGISTER INSTANCE instance_2 WITH CONFIG {"bolt_server": "127.0.0.1:7689", "management_server": "127.0.0.1:13012", "replication_server": "127.0.0.1:10002"};' \ + 'REGISTER INSTANCE instance_3 WITH CONFIG {"bolt_server": "127.0.0.1:7690", "management_server": "127.0.0.1:13013", "replication_server": "127.0.0.1:10003"};' \ + 'SET INSTANCE instance_1 TO MAIN;' \ + | mgconsole + + # Wait for the cluster to converge: the coordinator must advertise a main + # (WRITE) and at least one replica. + local converged=false instances="" + for _ in $(seq 1 60); do + instances="$(echo 'SHOW INSTANCES;' | mgconsole 2>/dev/null || true)" + if echo "$instances" | grep -qiw main && + echo "$instances" | grep -qiw replica; then + converged=true + break + fi + sleep 1 + done + if [ "$converged" != "true" ]; then + echo "error: HA cluster did not converge" >&2 + echo "$instances" >&2 + exit 1 + fi + + echo "HA cluster ready. Point clients at the coordinator:" + echo " MEMGRAPH_HA_COORDINATOR_HOST=$COORDINATOR_HOST" \ + "MEMGRAPH_HA_COORDINATOR_PORT=$COORDINATOR_PORT" +} + +case "${1:-start}" in + start) start_cluster ;; + stop) stop_cluster ;; + *) + echo "usage: $0 [start|stop]" >&2 + exit 2 + ;; +esac From 3bb0f4f6742dc632f3d1a88a6ff29afa29d0de02 Mon Sep 17 00:00:00 2001 From: matt Date: Fri, 10 Jul 2026 11:21:17 +0100 Subject: [PATCH 16/20] update workflow --- .github/workflows/ci.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 33bfde3..1cc28c7 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -240,6 +240,7 @@ jobs: make install" - name: Run routing example + if: ${{ env.MEMGRAPH_HA_COORDINATOR_HOST }} run: | docker exec -i testcontainer \ /mgclient/build-gcc/tests/example_routing_c \ From b40143a8520fa476a610742570d06cff06b221a5 Mon Sep 17 00:00:00 2001 From: matt Date: Fri, 10 Jul 2026 11:59:29 +0100 Subject: [PATCH 17/20] PR comment fixes --- .github/workflows/ci.yml | 5 ++++- src/mgrouting.c | 32 ++++++++++++++++++++++++-------- tests/routing.cpp | 27 +++++++++++++++++++++++++++ 3 files changed, 55 insertions(+), 9 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1cc28c7..45adeef 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -240,8 +240,11 @@ jobs: make install" - name: Run routing example - if: ${{ env.MEMGRAPH_HA_COORDINATOR_HOST }} run: | + if [[ -z "${MEMGRAPH_HA_COORDINATOR_HOST}}" || -z "${MEMGRAPH_HA_COORDINATOR_PORT}" ]]; then + echo "::notice::Skipping HA routing example" + exit 0 + fi docker exec -i testcontainer \ /mgclient/build-gcc/tests/example_routing_c \ "$MEMGRAPH_HA_COORDINATOR_HOST" "$MEMGRAPH_HA_COORDINATOR_PORT" diff --git a/src/mgrouting.c b/src/mgrouting.c index 45bd2bc..698ba9d 100644 --- a/src/mgrouting.c +++ b/src/mgrouting.c @@ -521,6 +521,9 @@ static int refresh_from_session(mg_router *router, mg_session *session) { } int mg_router_refresh(mg_router *router) { + if (!router) { + return MG_ERROR_BAD_PARAMETER; + } router->error[0] = '\0'; int last_status = MG_ERROR_TRANSIENT_ERROR; @@ -671,16 +674,29 @@ static int router_connect_role(mg_router *router, enum mg_routing_role role, mg_addr_list candidates; memset(&candidates, 0, sizeof(candidates)); - mg_routing_select_targets(router->table, role, router->resolver, - router->resolver_data, &router->read_index, - &candidates); + int select_status = mg_routing_select_targets( + router->table, role, router->resolver, router->resolver_data, + &router->read_index, &candidates); + if (select_status != 0) { + // Target selection failed (out of memory). This is not a transient + // condition, so report it rather than falling through and retrying. + router_set_error(router, "out of memory selecting routing targets"); + mg_addr_list_clear(&candidates); + return select_status; + } if (candidates.size == 0) { - char message[128]; - snprintf(message, sizeof(message), "no %s server in the routing table", - role_name(role)); - router_set_error(router, message); - last_status = MG_ERROR_TRANSIENT_ERROR; + // Only report "no server for this role" when we actually fetched a table + // that lacks one. If refresh failed and left no table, keep its more + // specific error and status (e.g. the coordinator was unreachable) + // rather than overwriting them with a misleading message. + if (router->table) { + char message[128]; + snprintf(message, sizeof(message), "no %s server in the routing table", + role_name(role)); + router_set_error(router, message); + last_status = MG_ERROR_TRANSIENT_ERROR; + } } else { for (uint32_t i = 0; i < candidates.size; ++i) { char *host = NULL; diff --git a/tests/routing.cpp b/tests/routing.cpp index 88a844b..a67e9ab 100644 --- a/tests/routing.cpp +++ b/tests/routing.cpp @@ -16,6 +16,7 @@ #include #include +#include #include #include @@ -326,6 +327,10 @@ mg_router *MakeRouter(const char *host, uint16_t port) { } } // namespace +TEST(RouterRefresh, RejectsNullRouter) { + EXPECT_EQ(mg_router_refresh(nullptr), MG_ERROR_BAD_PARAMETER); +} + TEST(RouterRefresh, AccessorsBeforeAnyRefresh) { mg_router *router = MakeRouter("127.0.0.1", 7687); ASSERT_NE(router, nullptr); @@ -349,6 +354,28 @@ TEST(RouterRefresh, FailsWhenSeedUnreachable) { mg_router_destroy(router); } +TEST(RouterConnect, UnreachableSeedKeepsRefreshError) { + // With no cached table and an unreachable seed, connecting must surface the + // refresh failure (e.g. the coordinator connection error), not overwrite it + // with a misleading "no server in the routing table". + mg_router *router = MakeRouter("127.0.0.1", 1); + ASSERT_NE(router, nullptr); + + ASSERT_NE(mg_router_refresh(router), 0); + const std::string refresh_error = mg_router_error(router); + ASSERT_FALSE(refresh_error.empty()); + + mg_session *session = nullptr; + int status = mg_router_connect_write(router, &session); + EXPECT_NE(status, 0); + EXPECT_EQ(session, nullptr); + // The connect surfaces the same coordinator-connection failure, not a + // fabricated routing-table message. + EXPECT_EQ(std::string(mg_router_error(router)), refresh_error); + + mg_router_destroy(router); +} + // Runs only against a real HA cluster; set MEMGRAPH_HA_COORDINATOR_HOST (and // optionally _PORT) to enable it. TEST(RouterRefresh, FetchesTableFromCoordinator) { From 6406e8b4b5e63a4d15031bff8ae1ae9a3d7264b9 Mon Sep 17 00:00:00 2001 From: matt Date: Fri, 10 Jul 2026 12:17:46 +0100 Subject: [PATCH 18/20] de-dup coordinator port --- tests/routing.cpp | 18 ++++++++---------- 1 file changed, 8 insertions(+), 10 deletions(-) diff --git a/tests/routing.cpp b/tests/routing.cpp index a67e9ab..8100c15 100644 --- a/tests/routing.cpp +++ b/tests/routing.cpp @@ -325,6 +325,13 @@ mg_router *MakeRouter(const char *host, uint16_t port) { mg_router_config_destroy(config); return router; } + +// Returns the coordinator port from MEMGRAPH_HA_COORDINATOR_PORT (default +// 7687). +uint16_t CoordinatorPort() { + const char *port_str = std::getenv("MEMGRAPH_HA_COORDINATOR_PORT"); + return port_str ? static_cast(std::atoi(port_str)) : 7687; +} } // namespace TEST(RouterRefresh, RejectsNullRouter) { @@ -383,10 +390,8 @@ TEST(RouterRefresh, FetchesTableFromCoordinator) { if (!host) { GTEST_SKIP() << "set MEMGRAPH_HA_COORDINATOR_HOST to run"; } - const char *port_str = std::getenv("MEMGRAPH_HA_COORDINATOR_PORT"); - uint16_t port = port_str ? static_cast(std::atoi(port_str)) : 7687; - mg_router *router = MakeRouter(host, port); + mg_router *router = MakeRouter(host, CoordinatorPort()); ASSERT_NE(router, nullptr); int status = mg_router_refresh(router); @@ -423,13 +428,6 @@ std::string ReplicationRole(mg_session *session) { } return role; } - -// Returns the coordinator port from MEMGRAPH_HA_COORDINATOR_PORT (default -// 7687). -uint16_t CoordinatorPort() { - const char *port_str = std::getenv("MEMGRAPH_HA_COORDINATOR_PORT"); - return port_str ? static_cast(std::atoi(port_str)) : 7687; -} } // namespace // Cluster-gated: set MEMGRAPH_HA_COORDINATOR_HOST to run these. The cluster's From 72bc58ca03555c81932a221071a83142d311c316 Mon Sep 17 00:00:00 2001 From: matt Date: Fri, 10 Jul 2026 12:39:34 +0100 Subject: [PATCH 19/20] clamp sleeps --- src/mgrouting.c | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/src/mgrouting.c b/src/mgrouting.c index 698ba9d..f6ec1b7 100644 --- a/src/mgrouting.c +++ b/src/mgrouting.c @@ -751,9 +751,14 @@ int mg_router_connect_write(mg_router *router, mg_session **session) { // Sleep for `seconds` (fractional). No-op for non-positive values. static void router_sleep_seconds(double seconds) { - if (seconds <= 0.0) { + // Skip non-positive values and NaN (NaN > 0.0 is false) + if (!(seconds > 0.0)) { return; } + // Clamp absurd or infinite values: no single backoff should exceed an hour. + if (seconds > 3600.0) { + seconds = 3600.0; + } #ifdef _WIN32 Sleep((DWORD)(seconds * 1000.0)); #else From 449ce85c7a9bb81caec8590222f0272c631fb43b Mon Sep 17 00:00:00 2001 From: matt Date: Fri, 10 Jul 2026 16:07:08 +0100 Subject: [PATCH 20/20] generalize ha cluster script for pymgclient --- tool/ha_cluster.sh | 125 ++++++++++++++++++++++++++++++--------------- 1 file changed, 84 insertions(+), 41 deletions(-) diff --git a/tool/ha_cluster.sh b/tool/ha_cluster.sh index 12f387f..a368383 100755 --- a/tool/ha_cluster.sh +++ b/tool/ha_cluster.sh @@ -7,10 +7,21 @@ # High availability is a Memgraph Enterprise feature, so `start` requires: # MEMGRAPH_ENTERPRISE_LICENSE, MEMGRAPH_ORGANIZATION_NAME # -# Every container runs on the Docker "host" network (override with -# MEMGRAPH_HA_NETWORK), so each instance uses a distinct port on localhost and -# the addresses the coordinator advertises (127.0.0.1:) are directly -# reachable -- no address resolver needed. +# The containers run on the Docker network named by MEMGRAPH_HA_NETWORK +# (default "host"), which also selects the addressing scheme: +# +# * "host": every container shares localhost, so each instance gets a +# distinct port and the advertised addresses are 127.0.0.1:. +# Reachable from anything on the host. This is what the mgclient tests use. +# +# * a user-defined bridge network (any other value): each container gets its +# own IP, so they share the standard ports and the advertised addresses are +# the container names (e.g. mg-data1:7687). Reachable from another container +# on the same network -- the model a containerized test runner (e.g. +# pymgclient's) uses. +# +# Either way the advertised addresses are directly reachable by the intended +# client, so no address resolver is needed. # # Usage: # tool/ha_cluster.sh start # start, register the topology, wait to converge @@ -23,20 +34,41 @@ set -Eeuo pipefail NETWORK="${MEMGRAPH_HA_NETWORK:-host}" IMAGE="${MEMGRAPH_HA_IMAGE:-memgraph/memgraph}" -COORDINATOR_HOST=127.0.0.1 -COORDINATOR_PORT=7691 -CONTAINERS=(mg-data1 mg-data2 mg-data3 mg-coord1 mg-coord2 mg-coord3) +DATA_NAMES=(mg-data1 mg-data2 mg-data3) +COORD_NAMES=(mg-coord1 mg-coord2 mg-coord3) + +# Per-instance ports and advertised address hosts, indexed 0..2. The two +# networking modes differ only in these tables. +if [ "$NETWORK" = "host" ]; then + DATA_HOST=(127.0.0.1 127.0.0.1 127.0.0.1) + COORD_HOST=(127.0.0.1 127.0.0.1 127.0.0.1) + DATA_BOLT=(7688 7689 7690) + COORD_BOLT=(7691 7692 7693) + DATA_MGMT=(13011 13012 13013) + COORD_MGMT=(13021 13022 13023) + COORD_CPORT=(12121 12122 12123) + DATA_REPL=(10001 10002 10003) +else + DATA_HOST=("${DATA_NAMES[@]}") + COORD_HOST=("${COORD_NAMES[@]}") + DATA_BOLT=(7687 7687 7687) + COORD_BOLT=(7687 7687 7687) + DATA_MGMT=(13011 13011 13011) + COORD_MGMT=(13011 13011 13011) + COORD_CPORT=(12121 12121 12121) + DATA_REPL=(10000 10000 10000) +fi # Run mgconsole (bundled in the memgraph image) against the bootstrap -# coordinator, reading queries from stdin. +# coordinator, reading queries from stdin. Executed inside mg-coord1, so +# localhost + coord1's bolt port reaches its own server in either mode. mgconsole() { - docker exec -i mg-coord1 mgconsole --host "$COORDINATOR_HOST" \ - --port "$COORDINATOR_PORT" + docker exec -i mg-coord1 mgconsole --host localhost --port "${COORD_BOLT[0]}" } stop_cluster() { - for name in "${CONTAINERS[@]}"; do + for name in "${DATA_NAMES[@]}" "${COORD_NAMES[@]}"; do docker stop "$name" >/dev/null 2>&1 || true done } @@ -49,34 +81,35 @@ start_cluster() { exit 1 fi + if [ "$NETWORK" != "host" ]; then + docker network create "$NETWORK" >/dev/null 2>&1 || true + fi + local lic=(-e "MEMGRAPH_ENTERPRISE_LICENSE=$MEMGRAPH_ENTERPRISE_LICENSE" \ -e "MEMGRAPH_ORGANIZATION_NAME=$MEMGRAPH_ORGANIZATION_NAME") - # Three data instances. On host networking every container shares localhost, - # so each needs a distinct set of ports. - docker run -d --rm --name mg-data1 --network "$NETWORK" "${lic[@]}" "$IMAGE" \ - --bolt-port=7688 --management-port=13011 --telemetry-enabled=false - docker run -d --rm --name mg-data2 --network "$NETWORK" "${lic[@]}" "$IMAGE" \ - --bolt-port=7689 --management-port=13012 --telemetry-enabled=false - docker run -d --rm --name mg-data3 --network "$NETWORK" "${lic[@]}" "$IMAGE" \ - --bolt-port=7690 --management-port=13013 --telemetry-enabled=false + # Three data instances. + local i + for i in 0 1 2; do + docker run -d --rm --name "${DATA_NAMES[$i]}" --network "$NETWORK" "${lic[@]}" \ + "$IMAGE" --bolt-port="${DATA_BOLT[$i]}" \ + --management-port="${DATA_MGMT[$i]}" --telemetry-enabled=false + done # Three coordinators for a Raft quorum. mg-coord1 is the bootstrap leader; # the others are added during registration below. - docker run -d --rm --name mg-coord1 --network "$NETWORK" "${lic[@]}" "$IMAGE" \ - --bolt-port=7691 --coordinator-id=1 --coordinator-port=12121 \ - --coordinator-hostname=127.0.0.1 --management-port=13021 --telemetry-enabled=false - docker run -d --rm --name mg-coord2 --network "$NETWORK" "${lic[@]}" "$IMAGE" \ - --bolt-port=7692 --coordinator-id=2 --coordinator-port=12122 \ - --coordinator-hostname=127.0.0.1 --management-port=13022 --telemetry-enabled=false - docker run -d --rm --name mg-coord3 --network "$NETWORK" "${lic[@]}" "$IMAGE" \ - --bolt-port=7693 --coordinator-id=3 --coordinator-port=12123 \ - --coordinator-hostname=127.0.0.1 --management-port=13023 --telemetry-enabled=false + for i in 0 1 2; do + docker run -d --rm --name "${COORD_NAMES[$i]}" --network "$NETWORK" "${lic[@]}" \ + "$IMAGE" --bolt-port="${COORD_BOLT[$i]}" --coordinator-id="$((i + 1))" \ + --coordinator-port="${COORD_CPORT[$i]}" \ + --coordinator-hostname="${COORD_HOST[$i]}" \ + --management-port="${COORD_MGMT[$i]}" --telemetry-enabled=false + done # Fail early (with logs) if any container didn't come up, rather than as an # opaque connection error later. sleep 10 - for name in "${CONTAINERS[@]}"; do + for name in "${DATA_NAMES[@]}" "${COORD_NAMES[@]}"; do if [ "$(docker inspect -f '{{.State.Running}}' "$name" 2>/dev/null)" != "true" ]; then echo "error: Memgraph HA container $name is not running" >&2 docker logs "$name" || true @@ -84,16 +117,26 @@ start_cluster() { fi done - # Register the topology on the bootstrap coordinator. Addresses are - # 127.0.0.1: because every container is on host networking. - printf '%s\n' \ - 'ADD COORDINATOR 2 WITH CONFIG {"bolt_server": "127.0.0.1:7692", "coordinator_server": "127.0.0.1:12122", "management_server": "127.0.0.1:13022"};' \ - 'ADD COORDINATOR 3 WITH CONFIG {"bolt_server": "127.0.0.1:7693", "coordinator_server": "127.0.0.1:12123", "management_server": "127.0.0.1:13023"};' \ - 'REGISTER INSTANCE instance_1 WITH CONFIG {"bolt_server": "127.0.0.1:7688", "management_server": "127.0.0.1:13011", "replication_server": "127.0.0.1:10001"};' \ - 'REGISTER INSTANCE instance_2 WITH CONFIG {"bolt_server": "127.0.0.1:7689", "management_server": "127.0.0.1:13012", "replication_server": "127.0.0.1:10002"};' \ - 'REGISTER INSTANCE instance_3 WITH CONFIG {"bolt_server": "127.0.0.1:7690", "management_server": "127.0.0.1:13013", "replication_server": "127.0.0.1:10003"};' \ - 'SET INSTANCE instance_1 TO MAIN;' \ - | mgconsole + # Register the topology on the bootstrap coordinator. Addresses use the mode's + # advertised host + port so the coordinator (and, in turn, clients) can reach + # every instance. + { + for i in 1 2; do + printf 'ADD COORDINATOR %d WITH CONFIG {"bolt_server": "%s:%s", "coordinator_server": "%s:%s", "management_server": "%s:%s"};\n' \ + "$((i + 1))" \ + "${COORD_HOST[$i]}" "${COORD_BOLT[$i]}" \ + "${COORD_HOST[$i]}" "${COORD_CPORT[$i]}" \ + "${COORD_HOST[$i]}" "${COORD_MGMT[$i]}" + done + for i in 0 1 2; do + printf 'REGISTER INSTANCE instance_%d WITH CONFIG {"bolt_server": "%s:%s", "management_server": "%s:%s", "replication_server": "%s:%s"};\n' \ + "$((i + 1))" \ + "${DATA_HOST[$i]}" "${DATA_BOLT[$i]}" \ + "${DATA_HOST[$i]}" "${DATA_MGMT[$i]}" \ + "${DATA_HOST[$i]}" "${DATA_REPL[$i]}" + done + printf 'SET INSTANCE instance_1 TO MAIN;\n' + } | mgconsole # Wait for the cluster to converge: the coordinator must advertise a main # (WRITE) and at least one replica. @@ -114,8 +157,8 @@ start_cluster() { fi echo "HA cluster ready. Point clients at the coordinator:" - echo " MEMGRAPH_HA_COORDINATOR_HOST=$COORDINATOR_HOST" \ - "MEMGRAPH_HA_COORDINATOR_PORT=$COORDINATOR_PORT" + echo " MEMGRAPH_HA_COORDINATOR_HOST=${COORD_HOST[0]}" \ + "MEMGRAPH_HA_COORDINATOR_PORT=${COORD_BOLT[0]}" } case "${1:-start}" in