Skip to content

Commit 8bd9322

Browse files
committed
feat: Support client-side routing
1 parent bbf7bda commit 8bd9322

10 files changed

Lines changed: 455 additions & 39 deletions

File tree

.gitignore

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,8 @@
1+
*.idx
2+
13
# build directory
24
build/
5+
bin/
36

47
# vim temporary files
58
*.swp

CLAUDE.md

Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,107 @@
1+
# CLAUDE.md
2+
3+
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
4+
5+
## What this is
6+
7+
`mgconsole` is a C++20 command-line client for the [Memgraph](https://memgraph.com) graph
8+
database. It talks the Bolt protocol via the bundled `mgclient` library, reads Cypher from
9+
either an interactive prompt (replxx) or stdin, and prints results as tabular / CSV / cypherl.
10+
11+
## Build
12+
13+
Dependencies are fetched and built from source via CMake `ExternalProject` (`gflags` pinned to
14+
`70c01a6`, `mgclient` pinned to `v1.5.0`); `OpenSSL` and a C++20 compiler must be present. The
15+
first configure/build is slow because of these external builds.
16+
17+
```bash
18+
cmake -B build -G Ninja -DCMAKE_BUILD_TYPE=Release . # macOS: add -DOPENSSL_ROOT_DIR="$(brew --prefix openssl)"
19+
cmake --build build
20+
cmake --install build # installs to /usr (Linux) or /usr/local (macOS) by default
21+
```
22+
23+
The binary lands at `build/src/mgconsole`. `compile_commands.json` is emitted into `build/`.
24+
`-Wall -Wextra -pedantic -Werror` is enabled — warnings break the build.
25+
26+
**Static / release build** (matches what ships): `./build-generic-linux.sh` builds inside the
27+
`memgraph/mgbuild` Docker image with the Memgraph toolchain and `-DMGCONSOLE_STATIC_SSL=ON`,
28+
producing `build/generic/mgconsole`.
29+
30+
## Test
31+
32+
Tests are wired into CTest and require a Memgraph instance (binary or Docker image) to run
33+
against — they spin one up, exercise the client, then tear it down.
34+
35+
```bash
36+
# Configure tests against a Docker Memgraph (no local binary needed):
37+
cmake -B build -G Ninja -DMEMGRAPH_USE_DOCKER=ON -DMEMGRAPH_DOCKER_IMAGE=memgraph/memgraph:latest
38+
cmake --build build
39+
ctest --verbose --test-dir build # runs all tests
40+
41+
ctest --test-dir build -R parameters-unit-test # single unit test (no DB needed)
42+
ctest --test-dir build -R mgconsole-test # end-to-end I/O tests (plaintext)
43+
ctest --test-dir build -R mgconsole-secure-test # same, over SSL
44+
```
45+
46+
To point at a local Memgraph binary instead of Docker, configure with
47+
`-DMEMGRAPH_PATH=/path/to/memgraph` and leave `MEMGRAPH_USE_DOCKER=OFF` (default).
48+
49+
Two test kinds:
50+
- **Unit** (`tests/unit/`): `parameters_test.cpp` links the standalone `params` library and runs
51+
without a database.
52+
- **End-to-end** (`tests/input_output/`): driven by `run-tests.sh`. For every file in `input/`,
53+
it runs the client once per output format and diffs stdout against the matching golden file in
54+
`output_tabular/`, `output_csv/`, etc. **When you change output formatting or add an input
55+
case, regenerate/add the corresponding golden file in every `output_*` directory** — the test
56+
matrix is the cross-product of inputs × formats.
57+
58+
## Architecture
59+
60+
`main.cpp` parses gflags, sets up signal handlers, and dispatches to exactly one of four "modes"
61+
based on whether stdin is a TTY and the `--import-mode` flag. Each mode lives in its own
62+
translation unit under the `mode::` namespace and implements a `Run(...)` entry point:
63+
64+
- **`mode::interactive`** (`interactive.cpp`) — chosen when stdin is a TTY. The replxx REPL loop:
65+
reads a query, handles `:param`/`:params`/`:help`/`:quit` commands, executes via `mgclient`,
66+
prints results, manages history, and reconnects (3 retries) on fatal connection errors.
67+
- **`mode::serial_import`** (`serial_import.cpp`) — default non-interactive (piped) path. Reads
68+
queries one at a time and executes them in order. This is the `DUMP DATABASE | mgconsole` path.
69+
- **`mode::batch_import`** (`batch_import.cpp`) — `--import-mode=batched-parallel`. EXPERIMENTAL.
70+
Classifies each query (via `QueryInfo`) as pre/vertex/edge/post, groups vertex and edge queries
71+
into batches, and executes batches concurrently across a thread pool of `--workers-number`
72+
Bolt sessions, with exponential backoff + retry on failure. Vertices are flushed before edges
73+
because edges depend on existing vertices. Query classification is heuristic — that's why this
74+
mode is experimental.
75+
- **`mode::parsing`** (`parsing.cpp`) — `--import-mode=parser`. Parses queries and prints
76+
`QueryInfo` stats without touching the database.
77+
78+
All four modes funnel through shared primitives in `src/utils/`, organized by namespace within
79+
`utils.hpp`/`utils.cpp` (a large ~1300-line file):
80+
81+
- **`query::`**`GetQuery()` reads and accumulates a complete (`;`-terminated, possibly
82+
multi-line) query from the input source, optionally producing `QueryInfo` (the has_create /
83+
has_match / has_merge / ... flags that drive batch classification). `ExecuteQuery()` /
84+
`ExecuteBatch()` run against an `mg_session`. `QueryResult` carries records, header, timing,
85+
notifications, and execution stats.
86+
- **`console::`** — TTY detection, line reading, `Echo*` output helpers (failure/info/stats).
87+
- **`format::`**`CsvOptions` and `OutputOptions`; `Output()` renders a result set as tabular,
88+
CSV, or cypherl.
89+
- **`utils::bolt`** (`bolt.hpp`/`bolt.cpp`) — `Config` struct and `MakeBoltSession()`, the single
90+
place sessions are created (direct or routing connection).
91+
92+
`src/parameters.{hpp,cpp}` is deliberately a **separate static library (`params`)** depending
93+
only on `mgclient`, so the `:param` parsing/storage logic can be unit-tested in isolation. Don't
94+
add heavier dependencies to it.
95+
96+
Concurrency support for batch mode is custom and lives in `utils/`: `thread_pool`, `future`
97+
(promise/future with notification hooks), `notifier`, and `synchronized`. `mg_memory.hpp` wraps
98+
raw `mgclient` C pointers in RAII unique-ptr types (`MgSessionPtr`, `MgValuePtr`, etc.) — use
99+
these rather than managing `mg_*` lifetimes by hand.
100+
101+
## Conventions
102+
103+
- Every source file carries the GPLv3 license header — copy it onto new files.
104+
- Formatting is enforced by `.clang-format` (Google base, 120 col). Run `clang-format` before
105+
committing.
106+
- `MG_ASSERT` / `MG_FAIL` (`utils/assert.hpp`) are the assertion/abort macros.
107+
- `date.hpp` is a large vendored third-party header (Howard Hinnant's date lib) — don't edit it.

src/batch_import.cpp

Lines changed: 35 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515

1616
#include "batch_import.hpp"
1717

18+
#include <chrono>
1819
#include <random>
1920
#include <thread>
2021
#include <unordered_map>
@@ -156,10 +157,19 @@ struct BatchExecutionContext {
156157
: batch_size(batch_size),
157158
max_batches(max_batches),
158159
max_concurrent_executions(max_concurrent_executions),
159-
thread_pool(max_concurrent_executions) {
160+
thread_pool(max_concurrent_executions),
161+
config(bolt_config) {
160162
sessions.reserve(max_concurrent_executions);
161163
for (uint64_t thread_i = 0; thread_i < max_concurrent_executions; ++thread_i) {
162-
sessions[thread_i] = MakeBoltSession(bolt_config);
164+
// Each worker connects to the resolved main. In routed mode every worker
165+
// re-routes independently at creation, so the coordinator may receive up
166+
// to `workers_number` (e.g. 32) ROUTE calls at startup. The TTL expiry of
167+
// the last route determines when sessions are refreshed (see Run).
168+
if (config.routed_connection) {
169+
sessions[thread_i] = utils::bolt::MakeRoutedBoltSession(config, &expiry);
170+
} else {
171+
sessions[thread_i] = MakeBoltSession(config);
172+
}
163173
if (!sessions[thread_i].get()) {
164174
MG_FAIL("a session uninitialized");
165175
}
@@ -175,6 +185,10 @@ struct BatchExecutionContext {
175185
utils::ThreadPool thread_pool{max_concurrent_executions};
176186
utils::Notifier notifier;
177187
std::vector<mg_memory::MgSessionPtr> sessions;
188+
/// Connection config; kept so workers can be reconnected / re-routed.
189+
utils::bolt::Config config;
190+
/// When the current routing table's TTL expires (routed mode only).
191+
std::chrono::steady_clock::time_point expiry{};
178192
};
179193

180194
Batches FetchBatches(BatchExecutionContext &execution_context) {
@@ -201,7 +215,7 @@ Batches FetchBatches(BatchExecutionContext &execution_context) {
201215
void ExecuteSerial(const std::vector<query::Query> &queries, BatchExecutionContext &context) {
202216
for (const auto &query : queries) {
203217
try {
204-
query::ExecuteQuery(context.sessions[0].get(), query.query);
218+
query::ExecuteQuery(context.sessions[0].get(), query.query, nullptr, context.config.db);
205219
} catch (const utils::ClientQueryException &e) {
206220
console::EchoFailure("Client received query exception", e.what());
207221
MG_FAIL("Unable to ExecuteSerial");
@@ -248,7 +262,7 @@ uint64_t ExecuteBatchesParallel(std::vector<query::Batch> &batches, BatchExecuti
248262
if (batch.backoff > 1) {
249263
std::this_thread::sleep_for(std::chrono::milliseconds(batch.backoff));
250264
}
251-
auto ret = query::ExecuteBatch(execution_context.sessions[thread_i].get(), batch);
265+
auto ret = query::ExecuteBatch(execution_context.sessions[thread_i].get(), batch, bolt_config.db);
252266
if (ret.is_executed) {
253267
batch.is_executed = true;
254268
executed_batches++;
@@ -265,7 +279,11 @@ uint64_t ExecuteBatchesParallel(std::vector<query::Batch> &batches, BatchExecuti
265279
promise->Fill(false);
266280
}
267281
if (mg_session_status(execution_context.sessions[thread_i].get()) == MG_SESSION_BAD) {
268-
execution_context.sessions[thread_i] = MakeBoltSession(bolt_config);
282+
if (bolt_config.routed_connection) {
283+
execution_context.sessions[thread_i] = utils::bolt::MakeRoutedBoltSession(bolt_config, nullptr);
284+
} else {
285+
execution_context.sessions[thread_i] = MakeBoltSession(bolt_config);
286+
}
269287
}
270288
});
271289
f_execs.insert_or_assign(thread_i, std::move(future));
@@ -287,6 +305,18 @@ int Run(const utils::bolt::Config &bolt_config, int batch_size, int workers_numb
287305
// (workers_number).
288306
BatchExecutionContext execution_context(batch_size, workers_number, workers_number, bolt_config);
289307
while (true) {
308+
// Round boundary checkpoint (single-threaded): in routed mode, if the
309+
// routing table's TTL has expired, re-route once to find the (possibly new)
310+
// main and reconnect every worker session to it. This keeps proactive TTL
311+
// refresh out of the parallel rounds where it would be race-prone.
312+
if (bolt_config.routed_connection && std::chrono::steady_clock::now() >= execution_context.expiry) {
313+
for (uint64_t thread_i = 0; thread_i < execution_context.max_concurrent_executions; ++thread_i) {
314+
execution_context.sessions[thread_i] = utils::bolt::MakeRoutedBoltSession(bolt_config, &execution_context.expiry);
315+
if (!execution_context.sessions[thread_i].get()) {
316+
MG_FAIL("failed to re-route a worker session after TTL expiry");
317+
}
318+
}
319+
}
290320
auto batches = FetchBatches(execution_context);
291321
if (batches.Empty()) {
292322
break;

src/interactive.cpp

Lines changed: 13 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -34,8 +34,8 @@ namespace params = query::params;
3434
// Evaluates a Cypher expression server-side and returns a copy of the resulting
3535
// value. Existing parameters are made available to the expression.
3636
mg_memory::MgValuePtr EvaluateParamExpression(mg_session *session, const std::string &expression,
37-
const params::ParamStore &store) {
38-
auto result = query::ExecuteQuery(session, "RETURN " + expression, store.AsMap().get());
37+
const params::ParamStore &store, const std::string &db) {
38+
auto result = query::ExecuteQuery(session, "RETURN " + expression, store.AsMap().get(), db);
3939
if (result.records.empty() || mg_list_size(result.records.front().get()) == 0) {
4040
throw utils::ClientQueryException("expression did not produce a value");
4141
}
@@ -59,7 +59,8 @@ void ListParams(const params::ParamStore &store) {
5959
// Handles a `:param`/`:params` command line. Query-level failures (e.g. a bad
6060
// expression) are reported without aborting the shell; fatal connection
6161
// failures propagate to the reconnect logic in Run.
62-
void HandleParamCommand(mg_session *session, params::ParamStore &store, const std::string &line) {
62+
void HandleParamCommand(mg_session *session, params::ParamStore &store, const std::string &line,
63+
const std::string &db) {
6364
const auto parsed = params::ParseParamCommand(line);
6465
if (!parsed.command) {
6566
console::EchoFailure("Invalid parameter command", parsed.error);
@@ -68,7 +69,7 @@ void HandleParamCommand(mg_session *session, params::ParamStore &store, const st
6869
switch (parsed.command->kind) {
6970
case params::ParamCommand::Kind::kSet:
7071
try {
71-
auto value = EvaluateParamExpression(session, parsed.command->expression, store);
72+
auto value = EvaluateParamExpression(session, parsed.command->expression, store, db);
7273
store.Set(parsed.command->name, value.get());
7374
console::EchoInfo("Set parameter '" + parsed.command->name + "'");
7475
} catch (const utils::ClientQueryException &e) {
@@ -153,8 +154,8 @@ int Run(utils::bolt::Config &bolt_config, const std::string &history, bool no_hi
153154
};
154155

155156
int num_retries = 3;
156-
auto session = MakeBoltSession(bolt_config);
157-
if (session.get() == nullptr) {
157+
utils::bolt::RoutedSession session(bolt_config);
158+
if (!session.Connected()) {
158159
cleanup_resources();
159160
return 1;
160161
}
@@ -179,15 +180,15 @@ int Run(utils::bolt::Config &bolt_config, const std::string &history, bool no_hi
179180

180181
try {
181182
if (query->is_param_command) {
182-
HandleParamCommand(session.get(), param_store, query->query);
183+
HandleParamCommand(session.Get(), param_store, query->query, bolt_config.db);
183184
auto history_ret = save_history();
184185
if (history_ret != 0) {
185186
cleanup_resources();
186187
return history_ret;
187188
}
188189
continue;
189190
}
190-
auto ret = query::ExecuteQuery(session.get(), query->query, param_store.AsMap().get());
191+
auto ret = query::ExecuteQuery(session.Get(), query->query, param_store.AsMap().get(), bolt_config.db);
191192
if (ret.records.size() > 0) {
192193
Output(ret.header, ret.records, output_opts, csv_opts);
193194
}
@@ -220,14 +221,12 @@ int Run(utils::bolt::Config &bolt_config, const std::string &history, bool no_hi
220221
console::EchoFailure("Client received connection exception", e.what());
221222
console::EchoInfo("Trying to reconnect...");
222223
bool is_connected = false;
223-
session.reset(nullptr);
224224
while (num_retries > 0) {
225225
--num_retries;
226-
session = utils::bolt::MakeBoltSession(bolt_config);
227-
if (session.get() == nullptr) {
228-
console::EchoFailure("Connection failure", mg_session_error(session.get()));
229-
session.reset(nullptr);
230-
} else {
226+
// In routed mode this re-fetches the routing table (failover); in
227+
// direct mode it reconnects to the same instance.
228+
session.Reconnect();
229+
if (session.Connected()) {
231230
is_connected = true;
232231
break;
233232
}

src/main.cpp

Lines changed: 19 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,12 @@ DEFINE_int32(port, 7687, "Server port.");
6060
DEFINE_string(username, "", "Database username.");
6161
DEFINE_string(password, "", "Database password.");
6262
DEFINE_bool(use_ssl, false, "Use SSL when connecting to the server.");
63+
DEFINE_string(connection_type, "direct",
64+
"(direct|routing) If routing, uses client-side routing protocol to connect to the main data instance.");
65+
// Selects the target database (sent in the ROUTE extra and in every RUN/BEGIN extra). When empty, the
66+
// server's default database is used. Multi-database selection requires Memgraph enterprise; community ignores it.
67+
DEFINE_string(db, "", "Database to use. When set, queries run against this database (enterprise multi-tenancy); "
68+
"a non-existent database fails with 'Unknown database name'. Empty uses the default database.");
6369

6470
// output
6571
DEFINE_bool(fit_to_screen, false, "Fit output width to screen width.");
@@ -180,13 +186,19 @@ int main(int argc, char **argv) {
180186

181187
#endif /* _WIN32 */
182188

183-
utils::bolt::Config bolt_config{
184-
.host = FLAGS_host,
185-
.port = FLAGS_port,
186-
.username = FLAGS_username,
187-
.password = FLAGS_password,
188-
.use_ssl = FLAGS_use_ssl,
189-
};
189+
auto const connection_type = utils::ToLowerCase(FLAGS_connection_type);
190+
if (connection_type != "direct" && connection_type != "routing") {
191+
console::EchoFailure("Unsupported connection type!", "Connection type can be 'direct' or 'routing'.");
192+
return 1;
193+
}
194+
195+
utils::bolt::Config bolt_config{.db = FLAGS_db,
196+
.host = FLAGS_host,
197+
.username = FLAGS_username,
198+
.password = FLAGS_password,
199+
.port = FLAGS_port,
200+
.use_ssl = FLAGS_use_ssl,
201+
.routed_connection = connection_type == "routing"};
190202

191203
if (console::is_a_tty(STDIN_FILENO)) { // INTERACTIVE
192204
auto const history_file = std::invoke([&]() -> std::string {

src/serial_import.cpp

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -21,8 +21,8 @@ using namespace std::string_literals;
2121

2222
int Run(const utils::bolt::Config &bolt_config, const format::CsvOptions &csv_opts,
2323
const format::OutputOptions &output_opts) {
24-
auto session = MakeBoltSession(bolt_config);
25-
if (session.get() == nullptr) {
24+
utils::bolt::RoutedSession session(bolt_config);
25+
if (!session.Connected()) {
2626
return 1;
2727
}
2828

@@ -36,7 +36,7 @@ int Run(const utils::bolt::Config &bolt_config, const format::CsvOptions &csv_op
3636
}
3737

3838
try {
39-
auto ret = query::ExecuteQuery(session.get(), query->query);
39+
auto ret = query::ExecuteQuery(session.Get(), query->query, nullptr, bolt_config.db);
4040
if (ret.records.size() > 0) {
4141
Output(ret.header, ret.records, output_opts, csv_opts);
4242
}

0 commit comments

Comments
 (0)