oxidedb 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (50) hide show
  1. oxidedb/__init__.py +1 -0
  2. oxidedb/channels.py +63 -0
  3. oxidedb/cli.py +388 -0
  4. oxidedb/client/__init__.py +22 -0
  5. oxidedb/client/node_client.py +303 -0
  6. oxidedb/client/remote_group_client.py +380 -0
  7. oxidedb/client/remote_node_client.py +317 -0
  8. oxidedb/client/routing.py +469 -0
  9. oxidedb/database.py +53 -0
  10. oxidedb/groups.py +73 -0
  11. oxidedb/launcher.py +1344 -0
  12. oxidedb/metadata/cache.py +320 -0
  13. oxidedb/metadata/publisher.py +252 -0
  14. oxidedb/metadata/service.py +817 -0
  15. oxidedb/proto/client_pb2.py +66 -0
  16. oxidedb/proto/client_pb2_grpc.py +312 -0
  17. oxidedb/proto/groups_pb2.py +58 -0
  18. oxidedb/proto/groups_pb2_grpc.py +212 -0
  19. oxidedb/proto/raft_pb2.py +50 -0
  20. oxidedb/proto/raft_pb2_grpc.py +183 -0
  21. oxidedb/raft/__init__.py +22 -0
  22. oxidedb/raft/client_servicer.py +336 -0
  23. oxidedb/raft/network_client.py +113 -0
  24. oxidedb/raft/node.py +1523 -0
  25. oxidedb/raft/raft_servicer.py +75 -0
  26. oxidedb/raft/recovery_notes.py +143 -0
  27. oxidedb/raft/recovery_runner.py +1136 -0
  28. oxidedb/raft/recovery_view.py +305 -0
  29. oxidedb/raft/shard_server.py +1429 -0
  30. oxidedb/raft/state_machine.py +492 -0
  31. oxidedb/raft/storage.py +402 -0
  32. oxidedb/shard/router.py +101 -0
  33. oxidedb/sql/executor.py +182 -0
  34. oxidedb/sql/parser.py +87 -0
  35. oxidedb/storage/__init__.py +15 -0
  36. oxidedb/storage/engine.py +225 -0
  37. oxidedb/storage/mvcc.py +363 -0
  38. oxidedb/storage/timestamp.py +32 -0
  39. oxidedb/transaction/__init__.py +3 -0
  40. oxidedb/transaction/coordinator.py +445 -0
  41. oxidedb/transaction/local.py +100 -0
  42. oxidedb/transaction/lock_cleaner.py +76 -0
  43. oxidedb/transaction/lock_resolver.py +215 -0
  44. oxidedb/transaction/smart_client.py +473 -0
  45. oxidedb/tso/tso.py +218 -0
  46. oxidedb-0.1.0.dist-info/METADATA +1124 -0
  47. oxidedb-0.1.0.dist-info/RECORD +50 -0
  48. oxidedb-0.1.0.dist-info/WHEEL +4 -0
  49. oxidedb-0.1.0.dist-info/entry_points.txt +3 -0
  50. oxidedb-0.1.0.dist-info/licenses/LICENSE +21 -0
oxidedb/__init__.py ADDED
@@ -0,0 +1 @@
1
+ __version__ = "0.1.0"
oxidedb/channels.py ADDED
@@ -0,0 +1,63 @@
1
+ """Channels to nodes: one per address, opened once, closed together.
2
+
3
+ Three kinds of client open a channel to a node - the factory that hands out shard handles,
4
+ and the two clients that reach the routing table's group and the timestamp group - and all
5
+ three want the same three things from it: a channel to an address, one channel per address
6
+ however many callers ask, and one place that closes them all. So it is one class, and a
7
+ second copy of it would be a second place to leak a channel from.
8
+
9
+ The address is the key rather than a node id, because an address is what a channel is opened
10
+ to: one node serves each of its shards and each of its groups on ports of its own, so an
11
+ address already names exactly one thing to talk to.
12
+ """
13
+
14
+ import threading
15
+ from typing import Dict, List
16
+
17
+ import grpc
18
+
19
+
20
+ #: How long one call waits before the node at the other end is written off. Long enough
21
+ #: to cover an election happening underneath a request, which is the slow case a client
22
+ #: meets in practice, and short enough that a node which is simply gone does not hold a
23
+ #: caller up for long. It is a per-call deadline: a commit that needs several calls is
24
+ #: bounded by several of these rather than by one.
25
+ DEFAULT_TIMEOUT = 4.0
26
+
27
+
28
+ class ChannelPool:
29
+ """Channels by address, opened when first asked for and kept until they are closed."""
30
+
31
+ def __init__(self) -> None:
32
+ self._lock = threading.Lock()
33
+ self._channels: Dict[str, grpc.Channel] = {}
34
+
35
+ def channel(self, address: str) -> grpc.Channel:
36
+ """The channel to ``address``, opening it the first time it is asked for."""
37
+ with self._lock:
38
+ channel = self._channels.get(address)
39
+ if channel is None:
40
+ channel = grpc.insecure_channel(address)
41
+ self._channels[address] = channel
42
+ return channel
43
+
44
+ def forget(self, address: str) -> None:
45
+ """Close and drop the channel to ``address``, if one is being kept.
46
+
47
+ The caller that found a channel broken is the only one that knows, and keeping it
48
+ would hand the same broken channel to the next caller. Forgetting an address that
49
+ was never asked for is not an error: a caller that found one broken and a caller
50
+ that never had one want the same thing to happen.
51
+ """
52
+ with self._lock:
53
+ channel = self._channels.pop(address, None)
54
+ if channel is not None:
55
+ channel.close()
56
+
57
+ def close(self) -> None:
58
+ """Close every channel this pool opened."""
59
+ with self._lock:
60
+ channels: List[grpc.Channel] = list(self._channels.values())
61
+ self._channels.clear()
62
+ for channel in channels:
63
+ channel.close()
oxidedb/cli.py ADDED
@@ -0,0 +1,388 @@
1
+ """The command line: the embedded database, or a cluster across a wire.
2
+
3
+ Four commands - get a key, set one, delete one, scan a range - and one shape for
4
+ them, with the mode deciding what is underneath. Without arguments the embedded
5
+ database answers, in memory, which is a way to poke at one command at a time;
6
+ ``--data-dir`` keeps that database's keyspace in a directory; and ``--server`` names a
7
+ node of a cluster that is already running, which is what makes this front end the
8
+ first caller in the repository that is not one of the cluster's own tests.
9
+
10
+ The two backends are not the same object and are not meant to be. A local ``Database``
11
+ holds MVCC storage in this process; a ``SmartClient`` holds a routing table read from
12
+ the metadata group, a clock read from the TSO group, and channels to the nodes that
13
+ serve each shard. What they share is the four commands, and this file is where those
14
+ are said once rather than once per backend.
15
+
16
+ ``--server`` wants the address a node's shard 0 listens at, because a node's other
17
+ ports are derived from that one - by ``ports_for`` in ``oxidedb/launcher.py``, which is
18
+ imported here rather than worked out a second time. Nothing has to be said about the
19
+ cluster for that: a node's ports are three fixed segments above its base port, so the
20
+ two group ports follow from the address alone. Several nodes may be named,
21
+ comma-separated or repeated, and all of them are used as seeds: no client knows which
22
+ member of either group leads, so one address it cannot use would otherwise be the end
23
+ of the walk.
24
+
25
+ A node is READY before it can be written to, so a ``--server`` command asks before it
26
+ sends: a timestamp from the clock's group, and a table naming every shard and its
27
+ leader. Nothing else here waits, and that wait is this file's rather than the
28
+ client's for a reason ``wait_until_routable`` gives.
29
+
30
+ ``get`` and ``scan`` also take a ``--consistency``, which chooses which copy of a
31
+ shard may answer and therefore what the read costs beyond the node that serves it:
32
+ ``strong`` asks the leader, ``follower`` any member of the set, and ``cached`` any
33
+ member at an index this client was given a moment earlier. ``set`` and ``delete``
34
+ take none of it, because a write has one place to go. What the three words mean is
35
+ said by ``--help`` on the two read commands rather than here, since that is where a
36
+ caller meets them first.
37
+ """
38
+
39
+ import argparse
40
+ import sys
41
+ import time
42
+
43
+ from oxidedb.client import RemoteNodeClientFactory
44
+ from oxidedb.database import Database
45
+ from oxidedb.launcher import ports_for
46
+ from oxidedb.metadata.cache import RoutingCache
47
+ from oxidedb.transaction.smart_client import READ_INDEX_TTL, Consistency, SmartClient
48
+
49
+
50
+ #: How long a ``--server`` command waits for a cluster that is still starting, and how
51
+ #: often it asks while it waits. A node says READY once its ports are bound, and what a
52
+ #: first command needs after that is a clock group that has elected and a publisher that
53
+ #: has written every shard's placement - placement arrives a command at a time and the
54
+ #: publisher writes on its own poll interval
55
+ #: (``metadata.publisher.DEFAULT_PUBLISH_INTERVAL``, half a second), so the lag is a few
56
+ #: of those intervals rather than a number about the machine. Five seconds is ten of
57
+ #: them: long enough that a cluster which is merely starting is never reported as a
58
+ #: failure, short enough that a command against an address with nothing behind it fails
59
+ #: rather than hangs. ``--wait 0`` asks once and reports.
60
+ CLUSTER_WAIT_SECONDS = 5.0
61
+ CLUSTER_WAIT_INTERVAL = 0.1
62
+
63
+
64
+ #: What ``--consistency`` offers, in the words ``get --help`` and ``scan --help``
65
+ #: show. It is said here, and at this length, because a shell shows it before it
66
+ #: shows anything else: the first documentation a caller meets is the flag's own,
67
+ #: earlier than the README and closer to the command, so it says what each level
68
+ #: costs rather than only what the three are called. The window comes from the
69
+ #: module that keeps the index rather than being written out again here - help that
70
+ #: said "100ms" while the cache had moved on would be wrong in the one place a
71
+ #: caller cannot check it.
72
+ CONSISTENCY_HELP = (
73
+ "which copy of a shard may answer the read, and what that costs (default: "
74
+ "%(default)s). strong: the shard's leader confirms a basis with a quorum and "
75
+ "answers at it - the slowest, and the level every read had before there was a "
76
+ "choice. follower: any member of the shard's set answers at a basis the node "
77
+ "obtains from the leader, so the hop is the node's rather than yours; as fresh "
78
+ "as strong, and spread over the set. cached: any member answers at a basis "
79
+ "this client was given earlier and keeps for "
80
+ f"{READ_INDEX_TTL:g}s - no hop at all, and the only level that may return "
81
+ f"data up to {READ_INDEX_TTL:g}s old"
82
+ )
83
+
84
+
85
+ class ClusterStore:
86
+ """The four commands against a cluster, over the transports that reach it.
87
+
88
+ The transports are why this is an object at all: a factory owns a channel per
89
+ address, and something has to close them when the command is over. Everything
90
+ else is delegation - the client underneath is the same ``SmartClient`` a test
91
+ drives, so a key set from a shell travels the path a key set from a test does,
92
+ through the same routing table and the same transaction coordinator.
93
+
94
+ The ``consistency`` is the level every read this store makes is answered at. It
95
+ is handed in rather than asked for at each call because a command makes one read
96
+ and the level belongs where the backend is chosen: a local database has one copy
97
+ of a key and nowhere to put a level, so ``_run`` would otherwise have to know
98
+ which backend it was talking to in order to pass a word one of the two does not
99
+ take.
100
+ """
101
+
102
+ def __init__(self, servers, consistency=Consistency.STRONG):
103
+ metadata_seeds, tso_seeds = _group_seeds(servers)
104
+ self._factory = RemoteNodeClientFactory(metadata_seeds=metadata_seeds,
105
+ tso_seeds=tso_seeds)
106
+ #: The group's client, held here rather than only inside the cache: the wait
107
+ #: asks the same one, and asking the factory twice would be asking for the same
108
+ #: object anyway - so this is where it is kept rather than a second channel.
109
+ self._metadata = self._factory.metadata_client()
110
+ #: The placement, read from the group the first time it is needed and kept
111
+ #: for the life of the command. ``None`` where a cluster would go because
112
+ #: there is no cluster object here to ask: what this client knows about
113
+ #: placement is the table, which is the point of the table.
114
+ self._table = RoutingCache(None, self._metadata, factory=self._factory)
115
+ self._client = SmartClient(self._factory.tso_client(), None,
116
+ router=self._table, factory=self._factory)
117
+ #: The level this store's reads are answered at. Kept with the client rather
118
+ #: than passed to the two calls that have one, because a command makes one
119
+ #: read: see this class's docstring for why the level arrives here at all.
120
+ self._consistency = consistency
121
+
122
+ def wait_until_routable(self, timeout=CLUSTER_WAIT_SECONDS):
123
+ """Wait for a cluster that has only just started, and say why if it never is.
124
+
125
+ A CLI invocation is one shot. With no second attempt and no caller holding
126
+ state for it, "not yet" and "not there" arrive as the same failure, and the
127
+ difference is the whole of what the user needs to know: a cluster that is still
128
+ electing is a reason to wait, an address with nothing behind it is a reason to
129
+ look at the command. So the command asks the two questions a command needs
130
+ answered - see ``_why_not_routable`` - and reports the last answer it got.
131
+
132
+ The wait is here rather than in the client because of what it would mean there:
133
+ a client answers or raises with its reason, and a caller with state to keep
134
+ decides what to do next. A command that retried itself would also be deciding,
135
+ on every caller's behalf, that a refusal is a reason to wait - and the design
136
+ notes settle the opposite for the one refusal that is about placement: a range
137
+ that is moving is a reason to read the table again. What this does is
138
+ narrower: it waits for the cluster to exist, and then sends the command once.
139
+ """
140
+ deadline = time.monotonic() + max(0.0, timeout)
141
+ reason = None
142
+ while True:
143
+ reason = self._why_not_routable()
144
+ if reason is None:
145
+ return
146
+ if time.monotonic() >= deadline:
147
+ break
148
+ time.sleep(CLUSTER_WAIT_INTERVAL)
149
+
150
+ raise RuntimeError(
151
+ f"the cluster behind --server was not ready within {timeout:.1f}s: "
152
+ f"{reason}. If it is still starting, give it longer with --wait; if "
153
+ f"nothing is listening at that address, that is the reason.")
154
+
155
+ def _why_not_routable(self):
156
+ """What a command still lacks, or ``None`` once a command could be routed.
157
+
158
+ The two things READY does not promise, asked the way any client has to ask for
159
+ them: a timestamp from the clock's group, and a table - read fresh, since the
160
+ cache can only hold what an earlier read found - that names every shard and a
161
+ leader for each. A sentence comes back rather than a flag because giving up
162
+ reports the last one it met, and the sentence is what a person can act on.
163
+ """
164
+ try:
165
+ self._factory.tso_client().get_timestamp()
166
+ except RuntimeError as failure:
167
+ return f"the clock group has no leader yet ({failure})"
168
+
169
+ try:
170
+ table = self._metadata.table(refresh=True)
171
+ except RuntimeError as failure:
172
+ return f"the routing table cannot be read yet ({failure})"
173
+
174
+ if not table.shards:
175
+ return "the table names no shard yet"
176
+
177
+ # Every key has a shard, or this table is one the publisher has not finished
178
+ # writing: the ranges are contiguous, so what is missing is a placement nobody
179
+ # has published yet. Counted rather than compared against a number the caller
180
+ # had to know - how many shards a cluster has is the table's own business, and a
181
+ # caller told to expect the wrong number would wait for a table that is as
182
+ # complete as it is ever going to be. Where the table starts depends on how the
183
+ # ranges were split, so the check is that it starts at the bottom of the
184
+ # keyspace rather than partway up it.
185
+ placements = sorted(table.shards.values(), key=lambda placed: placed.start)
186
+ covered = placements[0].start
187
+ if covered not in (b"", b"\x00"):
188
+ return (f"the table starts at {covered!r} rather than at the bottom of the "
189
+ f"keyspace, so the shards below it have not been published")
190
+ for placement in placements:
191
+ if placement.start != covered:
192
+ return (f"the table names no shard for the range from {covered!r} to "
193
+ f"{placement.start!r}, so it is not finished being written")
194
+ covered = placement.end
195
+ if covered != b"\xff":
196
+ return (f"the table covers the keyspace up to {covered!r}, and the shards "
197
+ f"above it have not been published")
198
+
199
+ nameless = [shard_id for shard_id, placement in table.shards.items()
200
+ if placement.leader_id is None]
201
+ if nameless:
202
+ return f"the table names no leader for shard {nameless[0]}"
203
+ return None
204
+
205
+ def get(self, key):
206
+ """The key's value, at the level this store was built with."""
207
+ return self._client.get(key, consistency=self._consistency)
208
+
209
+ def set(self, key, value):
210
+ return self._client.put(key, value)
211
+
212
+ def delete(self, key):
213
+ return self._client.delete(key)
214
+
215
+ def scan(self, start, end):
216
+ """Every key in the range, each piece of it at the level this store was
217
+ built with."""
218
+ return self._client.scan(start, end, consistency=self._consistency)
219
+
220
+ def close(self):
221
+ self._factory.close()
222
+
223
+
224
+ def _group_seeds(servers):
225
+ """The table's addresses and the clock's, derived from the nodes' own blocks.
226
+
227
+ A node takes its shards from its base port upwards, the routing table's group above
228
+ the shard segment, and the timestamp group above that. That is the arithmetic of
229
+ ``oxidedb/launcher.py``, imported rather than repeated, so an address worked out
230
+ here is the address those nodes bound - which is the whole reason it lives in one
231
+ function there, and the reason this needs nothing but the addresses.
232
+ """
233
+ metadata, tso = [], []
234
+ for server in servers:
235
+ host, port = server.rsplit(":", 1)
236
+ ports = ports_for(int(port))
237
+ metadata.append(f"{host}:{ports.metadata}")
238
+ tso.append(f"{host}:{ports.tso}")
239
+ return metadata, tso
240
+
241
+
242
+ def _servers(text):
243
+ """The addresses named by ``--server``: one, or several comma-separated."""
244
+ return [address.strip() for address in text.split(",") if address.strip()]
245
+
246
+
247
+ def _parser():
248
+ parser = argparse.ArgumentParser(description="OxideDB CLI")
249
+ parser.add_argument(
250
+ "--data-dir",
251
+ default=None,
252
+ metavar="DIR",
253
+ help="keep the data in DIR/data.sqlite3 (SQLite engine) instead of an "
254
+ "in-memory store that is discarded when the command exits",
255
+ )
256
+ parser.add_argument(
257
+ "--server",
258
+ default=None,
259
+ metavar="HOST:PORT",
260
+ help="a node of a running cluster, at the address its shard 0 listens at, "
261
+ "comma-separated for several nodes; the commands then go to that "
262
+ "cluster instead of to a local database",
263
+ )
264
+ parser.add_argument(
265
+ "--wait",
266
+ type=float,
267
+ default=CLUSTER_WAIT_SECONDS,
268
+ metavar="SECONDS",
269
+ help="how long a --server command waits for a cluster that is still starting "
270
+ "before it reports the reason it could not be routed (default: "
271
+ "%(default)s, which is a few of the publisher's poll intervals; "
272
+ "0 asks once and reports)",
273
+ )
274
+ subparsers = parser.add_subparsers(dest="command")
275
+
276
+ get_parser = subparsers.add_parser("get", help="Get a value by key")
277
+ get_parser.add_argument("key", help="The key to retrieve")
278
+ get_parser.add_argument("--consistency", choices=Consistency.ALL,
279
+ default=Consistency.STRONG, help=CONSISTENCY_HELP)
280
+
281
+ set_parser = subparsers.add_parser("set", help="Set a key-value pair")
282
+ set_parser.add_argument("key", help="The key")
283
+ set_parser.add_argument("value", help="The value")
284
+
285
+ delete_parser = subparsers.add_parser("delete", help="Delete a key")
286
+ delete_parser.add_argument("key", help="The key to delete")
287
+
288
+ scan_parser = subparsers.add_parser("scan", help="Scan keys in range")
289
+ scan_parser.add_argument("start_key", help="Start key (inclusive)")
290
+ scan_parser.add_argument("end_key", help="End key (exclusive)")
291
+ scan_parser.add_argument("--consistency", choices=Consistency.ALL,
292
+ default=Consistency.STRONG, help=CONSISTENCY_HELP)
293
+
294
+ return parser
295
+
296
+
297
+ def _open(parser, args):
298
+ """The backend: the embedded database, or a cluster.
299
+
300
+ One of the two, never both - a directory and a cluster are two different places
301
+ for the same key to be, and a command that quietly wrote to one of them would be
302
+ answering the wrong question.
303
+ """
304
+ servers = _servers(args.server) if args.server else []
305
+
306
+ if servers and args.data_dir is not None:
307
+ parser.error("--data-dir and --server are two different places to keep data")
308
+
309
+ if not servers:
310
+ # No level reaches the embedded database: it has one copy of a key, so there
311
+ # is no set to spread a read over and no index to answer at. The flag is on
312
+ # the two read commands and does nothing here, the way ``--wait`` already does
313
+ # without a --server.
314
+ return Database(data_dir=args.data_dir)
315
+
316
+ for address in servers:
317
+ host, _, port = address.rpartition(":")
318
+ if not host or not port.isdigit():
319
+ parser.error(f"--server wants host:port, and {address!r} is not that")
320
+
321
+ # ``--consistency`` is declared on the two read commands, so a write has no such
322
+ # attribute at all; the default is what a command that cannot choose already
323
+ # reads at.
324
+ store = ClusterStore(servers, getattr(args, "consistency", Consistency.STRONG))
325
+ try:
326
+ store.wait_until_routable(args.wait)
327
+ except RuntimeError:
328
+ # The wait failed, so no command will be sent: the channels this store opened
329
+ # are closed here rather than left to the caller's ``finally``, which has no
330
+ # store to close.
331
+ store.close()
332
+ raise
333
+ return store
334
+
335
+
336
+ def _run(args, store):
337
+ """The four commands, written once for both backends."""
338
+ if args.command == "get":
339
+ value = store.get(args.key.encode())
340
+ if value is None:
341
+ print("Key not found")
342
+ return 1
343
+ print(value.decode())
344
+ elif args.command == "set":
345
+ # No check: a write that could not be committed raises with its reason, so
346
+ # the only failure this command has is one main() already prints. A delete
347
+ # is not in that position - it writes no value, and a refusal is its answer.
348
+ store.set(args.key.encode(), args.value.encode())
349
+ print("OK")
350
+ elif args.command == "delete":
351
+ if store.delete(args.key.encode()) is False:
352
+ print("Key not deleted")
353
+ return 1
354
+ print("OK")
355
+ elif args.command == "scan":
356
+ for key, value in store.scan(args.start_key.encode(), args.end_key.encode()):
357
+ print(f"{key.decode()}: {value.decode()}")
358
+ else:
359
+ raise AssertionError(f"a subcommand with no branch here: {args.command!r}")
360
+ return 0
361
+
362
+
363
+ def main():
364
+ parser = _parser()
365
+ args = parser.parse_args()
366
+
367
+ if args.command is None:
368
+ parser.print_help()
369
+ return 0
370
+
371
+ store = None
372
+ try:
373
+ store = _open(parser, args)
374
+ return _run(args, store)
375
+ except RuntimeError as failure:
376
+ # What a cluster says no with arrives as the client's own errors - no leader
377
+ # this client can reach, a placement that covers no such key, a cluster that
378
+ # never became ready - and a shell wants that on one line rather than as a
379
+ # traceback.
380
+ print(f"{args.command}: {failure}")
381
+ return 1
382
+ finally:
383
+ if store is not None:
384
+ store.close()
385
+
386
+
387
+ if __name__ == "__main__":
388
+ sys.exit(main())
@@ -0,0 +1,22 @@
1
+ """The client side's view of a node, and of which node leads which shard.
2
+
3
+ ``node_client`` holds the primitives a client may ask one node, the protocol a factory
4
+ of handles answers, and the in-process implementation of both; ``remote_node_client`` is the
5
+ other implementation, over a channel, and ``raft/client_servicer.py`` is the other end of
6
+ it. ``routing`` turns a placement into one of those handles, so that the coordinator, the
7
+ resolver and the SQL executor ask the same question and get the same kind of answer whether
8
+ the node is in this process or across one. ``remote_group_client`` is the same idea for the
9
+ two groups that are not a shard - the routing table and the clock - which a client reaches
10
+ by walking the addresses it was seeded with rather than by looking them up.
11
+ """
12
+
13
+ from .node_client import (LocalNodeClient, LocalNodeClientFactory, NodeClient,
14
+ NodeClientFactory, NodeUnreachable)
15
+ from .remote_group_client import RemoteMetadataClient, RemoteTSOClient
16
+ from .remote_node_client import RemoteNodeClient, RemoteNodeClientFactory
17
+ from .routing import ShardLeaders, ask_shard
18
+
19
+ __all__ = ["LocalNodeClient", "LocalNodeClientFactory", "NodeClient",
20
+ "NodeClientFactory", "NodeUnreachable", "RemoteMetadataClient",
21
+ "RemoteNodeClient", "RemoteNodeClientFactory", "RemoteTSOClient",
22
+ "ShardLeaders", "ask_shard"]