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.
- oxidedb/__init__.py +1 -0
- oxidedb/channels.py +63 -0
- oxidedb/cli.py +388 -0
- oxidedb/client/__init__.py +22 -0
- oxidedb/client/node_client.py +303 -0
- oxidedb/client/remote_group_client.py +380 -0
- oxidedb/client/remote_node_client.py +317 -0
- oxidedb/client/routing.py +469 -0
- oxidedb/database.py +53 -0
- oxidedb/groups.py +73 -0
- oxidedb/launcher.py +1344 -0
- oxidedb/metadata/cache.py +320 -0
- oxidedb/metadata/publisher.py +252 -0
- oxidedb/metadata/service.py +817 -0
- oxidedb/proto/client_pb2.py +66 -0
- oxidedb/proto/client_pb2_grpc.py +312 -0
- oxidedb/proto/groups_pb2.py +58 -0
- oxidedb/proto/groups_pb2_grpc.py +212 -0
- oxidedb/proto/raft_pb2.py +50 -0
- oxidedb/proto/raft_pb2_grpc.py +183 -0
- oxidedb/raft/__init__.py +22 -0
- oxidedb/raft/client_servicer.py +336 -0
- oxidedb/raft/network_client.py +113 -0
- oxidedb/raft/node.py +1523 -0
- oxidedb/raft/raft_servicer.py +75 -0
- oxidedb/raft/recovery_notes.py +143 -0
- oxidedb/raft/recovery_runner.py +1136 -0
- oxidedb/raft/recovery_view.py +305 -0
- oxidedb/raft/shard_server.py +1429 -0
- oxidedb/raft/state_machine.py +492 -0
- oxidedb/raft/storage.py +402 -0
- oxidedb/shard/router.py +101 -0
- oxidedb/sql/executor.py +182 -0
- oxidedb/sql/parser.py +87 -0
- oxidedb/storage/__init__.py +15 -0
- oxidedb/storage/engine.py +225 -0
- oxidedb/storage/mvcc.py +363 -0
- oxidedb/storage/timestamp.py +32 -0
- oxidedb/transaction/__init__.py +3 -0
- oxidedb/transaction/coordinator.py +445 -0
- oxidedb/transaction/local.py +100 -0
- oxidedb/transaction/lock_cleaner.py +76 -0
- oxidedb/transaction/lock_resolver.py +215 -0
- oxidedb/transaction/smart_client.py +473 -0
- oxidedb/tso/tso.py +218 -0
- oxidedb-0.1.0.dist-info/METADATA +1124 -0
- oxidedb-0.1.0.dist-info/RECORD +50 -0
- oxidedb-0.1.0.dist-info/WHEEL +4 -0
- oxidedb-0.1.0.dist-info/entry_points.txt +3 -0
- 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"]
|